From df8e0eb46bca3dc01c17cc74a53b19aaacbe252b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 21:52:21 -0700 Subject: [PATCH 001/320] fix(web): discard stale highlights after file edits (#9902) --- .../files/fileEditorHighlight.test.ts | 264 ++++++++++++++++++ patches/@pierre%2Fdiffs@1.3.0-beta.10.patch | 12 + pnpm-lock.yaml | 10 +- 3 files changed, 281 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/files/fileEditorHighlight.test.ts diff --git a/apps/web/src/components/files/fileEditorHighlight.test.ts b/apps/web/src/components/files/fileEditorHighlight.test.ts new file mode 100644 index 000000000000..4de146835246 --- /dev/null +++ b/apps/web/src/components/files/fileEditorHighlight.test.ts @@ -0,0 +1,264 @@ +import { + FileRenderer, + getSharedHighlighter, + type BaseCodeOptions, + type DiffsHighlighter, + type FileContents, + type HighlightedToken, + type RenderRange, +} from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import * as NodeWorkerThreads from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DocumentChange = NonNullable["applyEdits"]>>; +interface Tokenizer { + readonly themeType: "light" | "dark"; + tokenize(change: DocumentChange, range: RenderRange): Map; + cleanUp(): void; +} + +// This dependency-internal tokenizer is the one used by Editor.#rerender. +const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); +const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { + EditorTokenizer: new (options: { + codeOptions: BaseCodeOptions; + highlighter: DiffsHighlighter; + textDocument: TextDocument; + setStyle: (style: string) => void; + onDeferTokenize: (lines: Map, theme: "light" | "dark") => void; + }) => Tokenizer; +}; + +const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); +const source = Array.from( + { length: 7_000 }, + (_, index) => + `export const section${index} =

Long wrapped source line ${index} for the file editor.

;`, +).join("\n"); +const options = { + theme: "pierre-dark", + themeType: "dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, + overflow: "wrap", + disableFileHeader: true, +} as const; +const range: RenderRange = { + startingLine: 6_950, + totalLines: 150, + bufferBefore: 0, + bufferAfter: 0, +}; + +interface HeldResponse { + data: WorkerResponse; + deliver: () => void; +} +let responses: HeldResponse[]; +let responseWaiters: ((response: HeldResponse) => void)[]; +let terminationPromises: Promise[]; +let pool: WorkerPoolManager; +let renderer: FileRenderer; +let tokenizer: Tokenizer; +let file: FileContents; +let document: TextDocument; + +function nextResponse(): Promise { + const response = responses.shift(); + return response + ? Promise.resolve(response) + : new Promise((resolve) => responseWaiters.push(resolve)); +} + +class WorkerTransport { + private readonly worker = new NodeWorkerThreads.Worker( + `const { parentPort, workerData } = require("node:worker_threads"); + globalThis.self = { + addEventListener(type, listener) { + if (type === "message") parentPort.on("message", data => listener({ data })); + if (type === "error") process.on("uncaughtException", listener); + } + }; + globalThis.postMessage = data => parentPort.postMessage(data); + import(workerData.moduleUrl);`, + { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, + ); + + addEventListener( + type: "message" | "error", + listener: (event: { data: WorkerResponse } | Error) => void, + ) { + if (type === "error") { + this.worker.on("error", listener); + return; + } + this.worker.on("message", (data: WorkerResponse) => { + const response = { data, deliver: () => listener({ data }) }; + if (data.type !== "success" || data.requestType !== "file") { + response.deliver(); + return; + } + const waiter = responseWaiters.shift(); + if (waiter) waiter(response); + else responses.push(response); + }); + } + + postMessage(message: WorkerRequest) { + this.worker.postMessage(message, []); + } + + terminate() { + terminationPromises.push(this.worker.terminate()); + } +} + +function applyChange(change: DocumentChange) { + // Keep the installed editor's non-DOM order, including the existing contents patch. + renderer.updateRenderCache(tokenizer.tokenize(change, range), tokenizer.themeType); + file.contents = document.getText(); + if (change.lineDelta !== 0) renderer.applyDocumentChange(document); +} + +function append(text: string) { + const position = document.positionAt(document.getText().length); + const change = document.applyEdits([ + { range: { start: position, end: position }, newText: text }, + ]); + expect(change).toBeDefined(); + applyChange(change!); +} + +function undo() { + const change = document.undo()?.[0]; + expect(change).toBeDefined(); + applyChange(change!); +} + +function renderContents() { + const result = renderer.renderFile(file, range); + expect(result?.totalLines).toBe(document.lineCount); + return renderer.renderFullHTML(result!); +} + +beforeEach(async () => { + responses = []; + responseWaiters = []; + terminationPromises = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setImmediate(() => callback(0)), + ); + vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + pool = new WorkerPoolManager( + // Adapt browser transport only; Pierre's real worker produces each response. + { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, + options, + ); + await pool.initialize(["tsx"]); + const highlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["tsx"], + preferredHighlighter: "shiki-wasm", + }); + file = { name: "wrapped.tsx", contents: source, cacheKey: "editable-file" }; + document = new TextDocument(file.name, source, "tsx"); + renderer = new FileRenderer(options, () => {}, pool); + tokenizer = new EditorTokenizer({ + codeOptions: options, + highlighter, + textDocument: document, + setStyle: () => {}, + onDeferTokenize: (lines, theme) => renderer.updateRenderCache(lines, theme), + }); + renderContents(); +}); + +afterEach(async () => { + tokenizer?.cleanUp(); + renderer?.cleanUp(); + pool?.terminate(); + await Promise.all(terminationPromises); + vi.unstubAllGlobals(); +}); + +describe("editable file highlighting", () => { + it("still accepts an asynchronous highlight when the file has not changed", async () => { + expect(renderContents()).not.toContain('style="color:'); + (await nextResponse()).deliver(); + expect(renderContents()).toContain('style="color:'); + expect(pool.getFileResultCache(file)).toBeDefined(); + }); + + it.each([1, 60])( + "ignores a dispatched highlight after %i Enter edits and highlights the new version", + async (count) => { + const oldResponse = await nextResponse(); + for (let index = 0; index < count; index += 1) append("\n"); + append("export const EDITED_MARKER = 1;"); + oldResponse.deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + const currentResponse = await nextResponse(); + currentResponse.deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); + expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); + expect(document.lineCount).toBe(7_000 + count); + }, + ); + + it("does not replace a same-line edit with stale tokens", async () => { + const oldResponse = await nextResponse(); + append(" EDITED_MARKER"); + oldResponse.deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + expect(document.lineCount).toBe(7_000); + (await nextResponse()).deliver(); + expect(renderContents()).toContain("EDITED_MARKER"); + }); + + it("keeps undo edits after an older highlight arrives", async () => { + const oldResponse = await nextResponse(); + append("\nexport const RETAINED_MARKER = 1;"); + append("\nexport const UNDONE_MARKER = 2;"); + undo(); + oldResponse.deliver(); + const html = renderContents(); + expect(html).toContain("RETAINED_MARKER"); + expect(html).not.toContain("UNDONE_MARKER"); + (await nextResponse()).deliver(); + expect(renderContents()).toContain("RETAINED_MARKER"); + undo(); + expect(document.getText()).toBe(source); + expect(renderContents()).not.toContain("RETAINED_MARKER"); + const redone = document.redo()?.[0]; + expect(redone).toBeDefined(); + applyChange(redone!); + expect(renderContents()).toContain("RETAINED_MARKER"); + }); + + it("evicts the pre-edit shared cache without losing already-highlighted lines", async () => { + (await nextResponse()).deliver(); + expect(pool.getFileResultCache(file)).toBeDefined(); + append("\nexport const EDITED_MARKER = 1;"); + expect(pool.getFileResultCache(file)).toBeUndefined(); + expect(renderContents()).toContain("EDITED_MARKER"); + const firstLines = renderer.renderFile(file, { ...range, startingLine: 0, totalLines: 20 }); + expect(renderer.renderFullHTML(firstLines!)).toContain('style="color:'); + }); + + it("reopens the edited file with the same cache key while an old response is pending", async () => { + const oldResponse = await nextResponse(); + append("\nexport const REOPENED_MARKER = 1;"); + renderer.cleanUp(); + renderer = new FileRenderer(options, () => {}, pool); + file = { ...file }; + expect(renderContents()).toContain("REOPENED_MARKER"); + oldResponse.deliver(); + (await nextResponse()).deliver(); + expect(renderContents()).toContain("REOPENED_MARKER"); + expect(renderContents()).toContain('style="color:'); + }); +}); diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index b342d4b5dd13..3b558fe56552 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -60,6 +60,18 @@ index e9f62f5..af82a46 100644 }; return merged; } +diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js +--- a/dist/renderers/FileRenderer.js ++++ b/dist/renderers/FileRenderer.js +@@ -163,6 +163,8 @@ + if (this.renderCache == null) return; + const { file, result } = this.renderCache; + if (result == null) return; ++ this.workerManager?.cleanUpTasks(this); ++ if (file.cacheKey != null) this.workerManager?.evictFileFromCache(file.cacheKey); + const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; + for (const [line, tokens] of dirtyLines) { + if (lineCache != null && line < lineCache.lines.length) { diff --git a/package.json b/package.json index ff61c90..1e170e5 100644 --- a/package.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d2bd2b64ecb3..e0978718463e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': 7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa + '@pierre/diffs@1.3.0-beta.10': c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -245,7 +245,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -586,7 +586,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13613,7 +13613,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -13627,7 +13627,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=7ef7cb0cbabb17c15cdb137554068b36f15f6f5265e73fb51452aa3380db91aa)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) From fd773172e7f26f2d98de3eb572f3317b0ad8d443 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 4 Sep 2026 21:57:49 -0700 Subject: [PATCH 002/320] feat(web): recall sent prompts with the up arrow (#9173) Press ArrowUp in an empty composer to get the last prompt sent in this thread. ArrowUp again goes further back, ArrowDown comes forward, and going past the newest prompt clears the composer. History is per thread and derived from the thread's user messages on the keypress, so nothing new is stored or synced. Send-time appends (terminal and element context, preview annotations, review comments, the ultrathink prefix) and app-composed sends are stripped. With text in the composer, arrows move the caret unless the caret is on the first or last visual line of an unedited recalled prompt. Modifiers, IME composition, menus, approvals, and pending questions take priority. Attachments are not restored. Prior art: #1778 by @PratyushChauhan, #4336 by @mfazekas, #7952 by @sethwebster. Co-Authored-By: Claude Fable 5.1 --- apps/web/src/components/ChatView.tsx | 4 +- .../src/components/ComposerPromptEditor.tsx | 97 ++++++++ apps/web/src/components/chat/ChatComposer.tsx | 100 +++++++- .../chat/composerPromptHistory.test.ts | 214 ++++++++++++++++++ .../components/chat/composerPromptHistory.ts | 212 +++++++++++++++++ apps/web/src/proposedPlan.ts | 5 +- docs/user/composer.md | 13 ++ 7 files changed, 641 insertions(+), 4 deletions(-) create mode 100644 apps/web/src/components/chat/composerPromptHistory.test.ts create mode 100644 apps/web/src/components/chat/composerPromptHistory.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index a6ae0e773f41..4ceba195e98b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -445,9 +445,8 @@ import { supportsServerUpdateThreadContinuation, } from "../versionSkew"; import { useAssetUrls } from "../assets/assetUrls"; +import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; -const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; @@ -7931,6 +7930,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadId={activeThreadId} activeThreadEnvironmentId={activeThread?.environmentId} activeThread={activeThread} + promptHistoryMessages={timelineMessages} isServerThread={isServerThread} isLocalDraftThread={isLocalDraftThread} forceExpandedOnMobile={forceExpandedMobileComposer && isDraftHeroState} diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 9066bee077c7..2ecb4c873e88 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -896,6 +896,13 @@ export interface ComposerPromptEditorHandle { expandedCursor: number; terminalContextIds: string[]; }; + /** + * True when a collapsed caret sits on the first ("start") or last ("end") + * visual line, counting soft wraps. Prompt history only claims ArrowUp and + * ArrowDown at these edges so arrows still move the caret inside multiline + * text. + */ + isCaretOnVisualEdge: (edge: "start" | "end") => boolean; } interface ComposerPromptEditorProps { @@ -929,6 +936,66 @@ interface ComposerPromptEditorProps { editorRef: React.RefObject; } +/** + * Client rect of the line the collapsed caret is on, as seen from `edge`. + * A caret at a soft-wrap boundary belongs to two visual lines and the + * range reports a rect for each, so take the one farthest from the edge + * under test: an ambiguous caret then never claims the key and the arrow + * moves the caret as usual. A collapsed range reports zero-height rects at + * some positions, so probe the adjacent character on the same side. When + * the range container is the paragraph itself (an empty line, or a caret + * beside an inline chip) measure the child next to the caret before + * falling back to the paragraph. + */ +function caretLineRect(range: Range, edge: "start" | "end"): DOMRect | null { + const collapsedRects = Array.from(range.getClientRects()).filter((rect) => rect.height > 0); + const collapsedRect = edge === "start" ? collapsedRects.at(-1) : collapsedRects[0]; + if (collapsedRect) return collapsedRect; + + const container = range.startContainer; + if (container.nodeType === Node.TEXT_NODE) { + const textNode = container as Text; + if (textNode.data.length === 0) return null; + const probeStart = Math.max( + 0, + Math.min( + edge === "start" ? range.startOffset : range.startOffset - 1, + textNode.data.length - 1, + ), + ); + const probeRange = document.createRange(); + probeRange.setStart(textNode, probeStart); + probeRange.setEnd(textNode, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + const boundingRect = probeRange.getBoundingClientRect(); + return boundingRect.height > 0 ? boundingRect : null; + } + + if (!(container instanceof HTMLElement)) return null; + // The caret sits between the paragraph's children, which is where Lexical + // puts it next to an inline chip. Measure the neighbouring child. + const neighbour = + container.childNodes[Math.max(0, range.startOffset - 1)] ?? + container.childNodes[range.startOffset]; + if (neighbour instanceof HTMLElement) { + const neighbourRect = neighbour.getBoundingClientRect(); + if (neighbourRect.height > 0) return neighbourRect; + } else if (neighbour instanceof Text && neighbour.data.length > 0) { + // Probe the character on the caret's side. A soft-wrapped text node's + // first rect is its first visual line, which may not be the caret's. + const isBeforeCaret = neighbour === container.childNodes[range.startOffset - 1]; + const probeStart = isBeforeCaret ? neighbour.data.length - 1 : 0; + const probeRange = document.createRange(); + probeRange.setStart(neighbour, probeStart); + probeRange.setEnd(neighbour, probeStart + 1); + const probeRect = Array.from(probeRange.getClientRects()).find((rect) => rect.height > 0); + if (probeRect) return probeRect; + } + const containerRect = container.getBoundingClientRect(); + return containerRect.height > 0 ? containerRect : null; +} + function ComposerCommandKeyPlugin(props: { onCommandKeyDown?: ( key: "ArrowDown" | "ArrowUp" | "Enter" | "Tab", @@ -1799,6 +1866,36 @@ function ComposerPromptEditorInner({ if (target) setOpenCitationComment(target); }, readSnapshot, + isCaretOnVisualEdge: (edge) => { + const snapshot = readSnapshot(); + if (snapshot.value.length === 0) return true; + const beforeCaret = snapshot.value.slice(0, snapshot.expandedCursor); + const afterCaret = snapshot.value.slice(snapshot.expandedCursor); + if (edge === "start" ? beforeCaret.includes("\n") : afterCaret.includes("\n")) { + return false; + } + const rootElement = editor.getRootElement(); + const selection = window.getSelection(); + if ( + !rootElement || + !selection || + !selection.isCollapsed || + selection.rangeCount === 0 || + !selection.anchorNode || + !rootElement.contains(selection.anchorNode) + ) { + return false; + } + const caretRect = caretLineRect(selection.getRangeAt(0), edge); + if (!caretRect) return false; + const edgeElement = + edge === "start" ? rootElement.firstElementChild : rootElement.lastElementChild; + const edgeRect = (edgeElement ?? rootElement).getBoundingClientRect(); + const threshold = caretRect.height / 2; + return edge === "start" + ? caretRect.top - edgeRect.top < threshold + : edgeRect.bottom - caretRect.bottom < threshold; + }, }), [editor, focusAt, readSnapshot], ); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 2b19d6ec26ba..10898e311cef 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -803,7 +803,12 @@ import { } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; import type { UnifiedSettings } from "@t3tools/contracts/settings"; -import { type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { type ChatMessage, type SessionPhase, type Thread, videoMimeType } from "../../types"; +import { + buildComposerPromptHistoryEntries, + stepComposerPromptHistory, + type ComposerPromptHistoryPosition, +} from "./composerPromptHistory"; import type { PendingUserInputDraftAnswer } from "../../pendingUserInput"; import type { PendingApproval, PendingUserInput } from "../../session-logic"; import type { ContextWindowSnapshot } from "../../lib/contextWindow"; @@ -1172,6 +1177,8 @@ export interface ChatComposerProps { activeThreadId: ThreadId | null; activeThreadEnvironmentId: EnvironmentId | undefined; activeThread: Thread | undefined; + /** Timeline messages including optimistic sends, for ArrowUp prompt recall. */ + promptHistoryMessages: ReadonlyArray; isServerThread: boolean; isLocalDraftThread: boolean; forceExpandedOnMobile: boolean; @@ -1312,6 +1319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, + promptHistoryMessages, isServerThread: _isServerThread, isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, @@ -1781,6 +1789,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) detectComposerTrigger(prompt, prompt.length), ); const [composerHighlightedItemId, setComposerHighlightedItemId] = useState(null); + // Active ArrowUp recall. Cleared on edit and on thread switch. + const promptHistoryPositionRef = useRef(null); const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState( null, ); @@ -2536,6 +2546,12 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) } promptRef.current = nextPrompt; setPrompt(nextPrompt); + // Any edit ends browsing, even one later undone by hand: typing a + // character and deleting it leaves the text equal to the recall, and + // ArrowDown must move the caret then, not clear the composer. + if (promptHistoryPositionRef.current?.recalled !== nextPrompt) { + promptHistoryPositionRef.current = null; + } if (!terminalContextIdListsEqual(composerTerminalContexts, terminalContextIds)) { setComposerDraftTerminalContexts( composerDraftTarget, @@ -2957,6 +2973,85 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }); }, [setIsComposerFocused]); + // ------------------------------------------------------------------ + // Prompt history (ArrowUp / ArrowDown) + // ------------------------------------------------------------------ + // Entries are built on the keypress, not per render: the timeline changes + // on every streamed delta and ArrowUp is rare. + const promptHistoryMessagesRef = useRef(promptHistoryMessages); + promptHistoryMessagesRef.current = promptHistoryMessages; + + // The composer persists across threads. A recall from thread A must not + // be treated as active in thread B, where the text-match fallback could + // otherwise turn B's own draft into a browsing position. + const promptHistoryTargetKey = composerTargetKey(composerDraftTarget); + useEffect(() => { + promptHistoryPositionRef.current = null; + }, [promptHistoryTargetKey]); + + const replacePromptFromHistory = useCallback( + (nextPrompt: string) => { + promptRef.current = nextPrompt; + setComposerDraftPrompt(composerDraftTarget, nextPrompt); + setComposerCursor(collapseExpandedComposerCursor(nextPrompt, nextPrompt.length)); + setComposerTrigger(null); + setComposerHighlightedItemId(null); + }, + [composerDraftTarget, promptRef, setComposerDraftPrompt], + ); + + const navigatePromptHistory = useCallback( + (direction: "backward" | "forward", event: KeyboardEvent): boolean => { + if (event.shiftKey || event.altKey || event.metaKey || event.ctrlKey || event.isComposing) { + return false; + } + if (isComposerApprovalState || pendingUserInputs.length > 0) return false; + // A composer holding an image, file, picked element, preview + // annotation, or review comment is not empty. Recalling text into it + // would send the old prompt with the new context, which is never what + // ArrowUp meant. + if ( + composerImagesRef.current.length > 0 || + composerFilesRef.current.length > 0 || + composerElementContextsRef.current.length > 0 || + composerPreviewAnnotations.length > 0 || + composerReviewComments.length > 0 + ) { + return false; + } + // A typed draft with no active recall can never step, so skip the + // layout read and the entry build for that common case. + if (promptHistoryPositionRef.current === null && promptRef.current.length > 0) { + return false; + } + const editor = composerEditorRef.current; + if (!editor?.isCaretOnVisualEdge(direction === "backward" ? "start" : "end")) { + return false; + } + const step = stepComposerPromptHistory({ + direction, + entries: buildComposerPromptHistoryEntries(promptHistoryMessagesRef.current), + position: promptHistoryPositionRef.current, + currentPrompt: promptRef.current, + }); + if (!step) return false; + promptHistoryPositionRef.current = step.position; + replacePromptFromHistory(step.prompt); + return true; + }, + [ + composerElementContextsRef, + composerFilesRef, + composerImagesRef, + composerPreviewAnnotations.length, + composerReviewComments.length, + isComposerApprovalState, + pendingUserInputs.length, + promptRef, + replacePromptFromHistory, + ], + ); + // ------------------------------------------------------------------ // Callbacks: command key // ------------------------------------------------------------------ @@ -2987,6 +3082,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return true; } } + if (key === "ArrowUp" || key === "ArrowDown") { + return navigatePromptHistory(key === "ArrowUp" ? "backward" : "forward", event); + } const submissionIntent = key === "Enter" ? composerSubmissionIntentForEnter({ diff --git a/apps/web/src/components/chat/composerPromptHistory.test.ts b/apps/web/src/components/chat/composerPromptHistory.test.ts new file mode 100644 index 000000000000..80a7d3b3dfb0 --- /dev/null +++ b/apps/web/src/components/chat/composerPromptHistory.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { appendElementContextsToPrompt } from "../../lib/elementContext"; +import { + appendTerminalContextsToPrompt, + materializeInlineTerminalContextPrompt, +} from "../../lib/terminalContext"; +import { appendReviewCommentsToPrompt, buildFileReviewComment } from "../../reviewCommentContext"; +import { buildPlanImplementationPrompt } from "../../proposedPlan"; +import { + ATTACHMENT_ONLY_BOOTSTRAP_PROMPT, + buildComposerPromptHistoryEntries, + recallableComposerPrompt, + stepComposerPromptHistory, + type ComposerPromptHistoryPosition, +} from "./composerPromptHistory"; + +const entries = buildComposerPromptHistoryEntries([ + { id: "m1", role: "user", text: "first" }, + { id: "a1", role: "assistant", text: "reply" }, + { id: "m2", role: "user", text: "second" }, + { id: "m3", role: "user", text: "third" }, +]); + +function backward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) { + return stepComposerPromptHistory({ direction: "backward", entries, position, currentPrompt }); +} + +function forward(position: ComposerPromptHistoryPosition | null, currentPrompt: string) { + return stepComposerPromptHistory({ direction: "forward", entries, position, currentPrompt }); +} + +describe("recallableComposerPrompt", () => { + it("strips send-time context blocks and the ultrathink prefix", () => { + const withTerminal = appendTerminalContextsToPrompt("Investigate this", [ + { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 12, + lineEnd: 13, + text: "git status\nOn branch main", + }, + ]); + const withElement = appendElementContextsToPrompt(withTerminal, [ + { + pageUrl: "https://example.com", + pageTitle: "Example", + tagName: "button", + selector: "button.submit", + htmlPreview: "", + componentName: null, + source: null, + styles: "", + }, + ]); + expect(recallableComposerPrompt(`Ultrathink:\n${withElement}`)).toBe("Investigate this"); + }); + + it("removes inline terminal labels along with their trailing block", () => { + const context = { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 12, + lineEnd: 13, + text: "git status", + }; + const typed = materializeInlineTerminalContextPrompt("Look at \uFFFC please", [context]); + expect(typed).toBe("Look at @terminal-1:12-13 please"); + const sent = appendTerminalContextsToPrompt(typed, [context]); + expect(recallableComposerPrompt(sent)).toBe("Look at please"); + }); + + it("removes one label per chip and leaves other whitespace alone", () => { + const context = { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "ls", + }; + const typed = "@terminal-1:4 typed twice: @terminal-1:4\n indented code"; + const sent = appendTerminalContextsToPrompt(typed, [context]); + expect(recallableComposerPrompt(sent)).toBe("typed twice: @terminal-1:4\n indented code"); + }); + + it("does not strip a typed label that only starts with the chip label", () => { + const context = { + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 4, + lineEnd: 4, + text: "ls", + }; + const typed = "see @terminal-1:40 and @terminal-1:4-12 then @terminal-1:4"; + const sent = appendTerminalContextsToPrompt(typed, [context]); + expect(recallableComposerPrompt(sent)).toBe("see @terminal-1:40 and @terminal-1:4-12 then"); + }); + + it("strips only the review comments appended at the end", () => { + const comment = buildFileReviewComment({ + id: "comment-1", + filePath: "src/app.ts", + startLine: 2, + endLine: 3, + text: "Keep this configurable.", + contents: "one\ntwo\nthree", + }); + const sent = appendReviewCommentsToPrompt("Please update this.", [comment]); + expect(recallableComposerPrompt(sent)).toBe("Please update this."); + const midPrompt = appendReviewCommentsToPrompt("Before", [comment]) + "\n\nAfter"; + expect(recallableComposerPrompt(midPrompt)).toBe(midPrompt); + // A typed block earlier in the prompt survives when the trailing one goes. + const both = appendReviewCommentsToPrompt(midPrompt, [comment]); + expect(recallableComposerPrompt(both)).toBe(midPrompt); + }); + + it("returns an empty string for app-composed sends", () => { + expect(recallableComposerPrompt(" ")).toBe(""); + expect(recallableComposerPrompt(ATTACHMENT_ONLY_BOOTSTRAP_PROMPT)).toBe(""); + expect(recallableComposerPrompt(buildPlanImplementationPrompt("# Plan\n1. do it"))).toBe(""); + }); +}); + +describe("buildComposerPromptHistoryEntries", () => { + it("keeps user messages with text, oldest first", () => { + expect(entries.map((entry) => entry.prompt)).toEqual(["first", "second", "third"]); + }); + + it("collapses consecutive duplicates onto the newest message id", () => { + const collapsed = buildComposerPromptHistoryEntries([ + { id: "m1", role: "user", text: "same" }, + { id: "m2", role: "user", text: "same" }, + { id: "m3", role: "user", text: "other" }, + { id: "m4", role: "user", text: "same" }, + ]); + expect(collapsed).toEqual([ + { id: "m2", prompt: "same" }, + { id: "m3", prompt: "other" }, + { id: "m4", prompt: "same" }, + ]); + }); +}); + +describe("stepComposerPromptHistory", () => { + it("does not start browsing from a non-empty draft", () => { + expect(backward(null, "typing")).toBeNull(); + }); + + it("walks back from the newest entry", () => { + const first = backward(null, ""); + expect(first).toEqual({ position: { entryId: "m3", recalled: "third" }, prompt: "third" }); + expect(backward(first!.position, "third")?.prompt).toBe("second"); + }); + + it("is a no-op at the oldest entry so the caret keeps moving", () => { + expect(backward({ entryId: "m1", recalled: "first" }, "first")).toBeNull(); + }); + + it("walks forward and empties the composer past the newest entry", () => { + const newer = forward({ entryId: "m2", recalled: "second" }, "second"); + expect(newer?.prompt).toBe("third"); + expect(forward(newer!.position, "third")).toEqual({ position: null, prompt: "" }); + }); + + it("treats an edited recall as a fresh draft", () => { + const position: ComposerPromptHistoryPosition = { entryId: "m3", recalled: "third" }; + expect(backward(position, "third edited")).toBeNull(); + expect(forward(position, "third edited")).toBeNull(); + // Sent and cleared: ArrowUp starts over from the newest entry. + expect(backward(position, "")?.position).toEqual({ entryId: "m3", recalled: "third" }); + }); + + it("does nothing on forward when not browsing", () => { + expect(forward(null, "")).toBeNull(); + }); + + it("follows the entry by id when the list changes under it", () => { + const grown = buildComposerPromptHistoryEntries([ + { id: "m0", role: "user", text: "zeroth" }, + { id: "m1", role: "user", text: "A" }, + { id: "m2", role: "user", text: "B" }, + { id: "m3", role: "user", text: "A" }, + ]); + const older = stepComposerPromptHistory({ + direction: "backward", + entries: grown, + position: { entryId: "m1", recalled: "A" }, + currentPrompt: "A", + }); + expect(older?.prompt).toBe("zeroth"); + // Unknown id with no matching text: browsing is over. + const missing = stepComposerPromptHistory({ + direction: "forward", + entries: grown, + position: { entryId: "gone", recalled: "not sent" }, + currentPrompt: "not sent", + }); + expect(missing).toBeNull(); + }); + + it("falls back to matching text when a duplicate collapse retires the id", () => { + const collapsed = buildComposerPromptHistoryEntries([ + { id: "m1", role: "user", text: "first" }, + { id: "m3", role: "user", text: "A" }, + ]); + const step = stepComposerPromptHistory({ + direction: "backward", + entries: collapsed, + position: { entryId: "m2", recalled: "A" }, + currentPrompt: "A", + }); + expect(step?.prompt).toBe("first"); + }); +}); diff --git a/apps/web/src/components/chat/composerPromptHistory.ts b/apps/web/src/components/chat/composerPromptHistory.ts new file mode 100644 index 000000000000..c4b6b4c0f93f --- /dev/null +++ b/apps/web/src/components/chat/composerPromptHistory.ts @@ -0,0 +1,212 @@ +import { extractTrailingElementContexts } from "../../lib/elementContext"; +import { extractTrailingPreviewAnnotation } from "../../lib/previewAnnotation"; +import { extractTrailingTerminalContexts } from "../../lib/terminalContext"; +import { PLAN_IMPLEMENTATION_PROMPT_PREFIX } from "../../proposedPlan"; + +/** + * Terminal-style prompt recall for the composer. ArrowUp on an empty + * composer walks back through the active thread's sent prompts, ArrowDown + * walks forward and restores the unsent draft past the newest entry. + * + * History is per thread and text only. It is derived from the thread's user + * messages on every keypress, so there is no store to persist or sync. + */ + +const CLAUDE_ULTRATHINK_PREFIX = "Ultrathink:\n"; +const REVIEW_COMMENT_BLOCK_PATTERN = /]*>[\s\S]*?<\/review_comment>/g; + +/** Text sent in place of an empty prompt when a message is attachments only. */ +export const ATTACHMENT_ONLY_BOOTSTRAP_PROMPT = + "[User attached one or more files without additional text. Respond using the conversation context and the attached files.]"; + +export interface ComposerPromptHistoryMessage { + readonly id: string; + readonly role: string; + readonly text: string; +} + +export interface ComposerPromptHistoryEntry { + readonly id: string; + readonly prompt: string; +} + +/** + * Active recall. `entryId` is resolved against the current entries on every + * step, so a server ack replacing an optimistic message or an older page + * loading cannot move the position. `recalled` is the text put in the + * composer; once the composer no longer matches it, the user has edited or + * sent and browsing is over. + */ +export interface ComposerPromptHistoryPosition { + readonly entryId: string; + readonly recalled: string; +} + +/** + * Prefer the id. A consecutive duplicate collapse can retire the recalled + * id while the same text lives on under a newer one, so fall back to the + * newest entry with matching text. + */ +function findActive( + entries: ReadonlyArray, + position: ComposerPromptHistoryPosition, +): number { + const byId = entries.findIndex((entry) => entry.id === position.entryId); + if (byId >= 0) return byId; + return entries.findLastIndex((entry) => entry.prompt === position.recalled); +} + +export interface ComposerPromptHistoryStep { + readonly position: ComposerPromptHistoryPosition | null; + readonly prompt: string; +} + +/** + * Drop only the review comments appended at send time, which sit at the + * end. Cuts the original string at the start of the trailing run of blocks + * so any review comment block the user typed earlier stays byte-for-byte. + */ +function stripTrailingReviewComments(prompt: string): string { + let cut = prompt.length; + for (const match of [...prompt.matchAll(REVIEW_COMMENT_BLOCK_PATTERN)].toReversed()) { + const blockEnd = match.index + match[0].length; + if (prompt.slice(blockEnd, cut).trim().length > 0) break; + cut = match.index; + } + return cut === prompt.length ? prompt : prompt.slice(0, cut).trimEnd(); +} + +/** + * Inline terminal chips are sent as `@terminal-1:12-13` labels in the text + * with their content in the trailing block. Once the block is stripped the + * label points at nothing, so remove it too. Each block entry removes one + * label (the first match) and the single space beside it. Nothing else in + * the prompt is touched, so indented code and typed labels survive. Block + * headers look like `Terminal 1 lines 12-13`. + */ +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function stripInlineTerminalLabels(prompt: string, headers: ReadonlyArray): string { + let result = prompt; + for (const header of headers) { + const match = /^(.+?) lines? (\d+(?:-\d+)?)$/.exec(header); + if (!match) continue; + const label = `@${match[1]!.trim().toLowerCase().replace(/\s+/g, "-")}:${match[2]}`; + // Whole label only: `@terminal-1:4` must not match inside `@terminal-1:40` + // or `@terminal-1:4-12`. + const labelPattern = new RegExp(`${escapeRegExp(label)}(?![\\d-])`); + const index = result.search(labelPattern); + if (index < 0) continue; + let end = index + label.length; + let start = index; + if (result[end] === " ") end += 1; + else if (result[start - 1] === " ") start -= 1; + result = result.slice(0, start) + result.slice(end); + } + return result; +} + +/** + * Reduce a sent message to the text the user typed. Send-time appends + * (terminal and element context blocks, preview annotations, review + * comments, the Claude ultrathink prefix) are stripped so a recalled prompt + * never carries stale context from another turn. + */ +export function recallableComposerPrompt(messageText: string): string { + let prompt = messageText.trim(); + if (prompt.startsWith(CLAUDE_ULTRATHINK_PREFIX)) { + prompt = prompt.slice(CLAUDE_ULTRATHINK_PREFIX.length); + } + + while (prompt.length > 0) { + const withoutReviewComments = stripTrailingReviewComments(prompt); + if (withoutReviewComments !== prompt) { + prompt = withoutReviewComments; + continue; + } + const previewAnnotation = extractTrailingPreviewAnnotation(prompt); + if (previewAnnotation.annotation) { + prompt = previewAnnotation.promptText; + continue; + } + const elementContexts = extractTrailingElementContexts(prompt); + if (elementContexts.contextCount > 0) { + prompt = elementContexts.promptText; + continue; + } + const terminalContexts = extractTrailingTerminalContexts(prompt); + if (terminalContexts.contextCount > 0) { + prompt = stripInlineTerminalLabels( + terminalContexts.promptText, + terminalContexts.contexts.map((context) => context.header), + ); + continue; + } + break; + } + + // App-composed sends are not text the user typed, so they are not history. + const trimmed = prompt.trim(); + if ( + trimmed === ATTACHMENT_ONLY_BOOTSTRAP_PROMPT || + trimmed.startsWith(PLAN_IMPLEMENTATION_PROMPT_PREFIX) + ) { + return ""; + } + return trimmed; +} + +/** + * Oldest first. Consecutive identical prompts collapse into the newest one, + * matching shell `HISTCONTROL=ignoredups`. Image-only sends have no text and + * are skipped. + */ +export function buildComposerPromptHistoryEntries( + messages: ReadonlyArray, +): ComposerPromptHistoryEntry[] { + const entries: ComposerPromptHistoryEntry[] = []; + for (const message of messages) { + if (message.role !== "user") continue; + const prompt = recallableComposerPrompt(message.text); + if (prompt.length === 0) continue; + const previous = entries[entries.length - 1]; + if (previous && previous.prompt === prompt) { + entries[entries.length - 1] = { id: message.id, prompt }; + continue; + } + entries.push({ id: message.id, prompt }); + } + return entries; +} + +/** + * Returns null when the key should fall through to normal caret movement. + * Backward starts only from an empty composer and stops at the oldest entry. + * Forward past the newest entry empties the composer and ends browsing. An + * edited or sent recall no longer matches `recalled`, so browsing restarts + * from scratch on the next backward step. + */ +export function stepComposerPromptHistory(input: { + readonly direction: "backward" | "forward"; + readonly entries: ReadonlyArray; + readonly position: ComposerPromptHistoryPosition | null; + readonly currentPrompt: string; +}): ComposerPromptHistoryStep | null { + const { entries, position, currentPrompt } = input; + const activeIndex = + position && position.recalled === currentPrompt ? findActive(entries, position) : -1; + + if (input.direction === "backward") { + if (activeIndex < 0 && currentPrompt.length > 0) return null; + const entry = entries[activeIndex < 0 ? entries.length - 1 : activeIndex - 1]; + if (!entry) return null; + return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; + } + + if (activeIndex < 0) return null; + const entry = entries[activeIndex + 1]; + if (!entry) return { position: null, prompt: "" }; + return { position: { entryId: entry.id, recalled: entry.prompt }, prompt: entry.prompt }; +} diff --git a/apps/web/src/proposedPlan.ts b/apps/web/src/proposedPlan.ts index 48186392e8a3..525be17a72e0 100644 --- a/apps/web/src/proposedPlan.ts +++ b/apps/web/src/proposedPlan.ts @@ -70,8 +70,11 @@ function sanitizePlanFileSegment(input: string): string { return sanitized.length > 0 ? sanitized : "plan"; } +/** Prefix of the message the app sends when the user approves a plan. */ +export const PLAN_IMPLEMENTATION_PROMPT_PREFIX = "PLEASE IMPLEMENT THIS PLAN:\n"; + export function buildPlanImplementationPrompt(planMarkdown: string): string { - return `PLEASE IMPLEMENT THIS PLAN:\n${planMarkdown.trim()}`; + return `${PLAN_IMPLEMENTATION_PROMPT_PREFIX}${planMarkdown.trim()}`; } export function resolvePlanFollowUpSubmission(input: { draftText: string; planMarkdown: string }): { diff --git a/docs/user/composer.md b/docs/user/composer.md index 2220fa53c0ec..4a8df5333664 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -56,6 +56,19 @@ is unavailable or has changed, the saved quote remains readable. Mobile displays saved quotes and comments, but does not create citations or navigate to their sources. +## Recall a sent prompt + +Press `ArrowUp` in an empty composer to bring back the last prompt you sent in this thread. Press +`ArrowUp` again to go further back, and `ArrowDown` to come forward. Moving forward past the newest +prompt clears the composer. Recall walks the prompts loaded in the thread. Attachments, terminal +context, and other extras from the original message are not restored, only the text you typed. A +composer that holds an attachment or a picked element does not count as empty. + +When the composer has text, the arrow keys move the caret as usual. Recall takes over only while +the text is an unedited recalled prompt, with the caret on the first visual line for `ArrowUp` or +the last visual line for `ArrowDown`, counting wrapped lines. Editing a recalled prompt turns it +into a normal draft. + ## Prompt stash On web and desktop, press `Cmd+S` on macOS or `Ctrl+S` on Windows and Linux to save From c7dc3cbd068a93c9fb5027df7bea719d16e4ffbe Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:04:49 -0700 Subject: [PATCH 003/320] fix(server): keep mise-owned npm packages manual-only (#9927) --- .../src/provider/Drivers/CodexDriver.test.ts | 116 ++++++++++++++++++ .../src/provider/providerMaintenance.ts | 6 + 2 files changed, 122 insertions(+) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 003c6f53d317..7e2f2f8864b6 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -102,4 +102,120 @@ it.layer(testLayer)("CodexDriver", (it) => { expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), Effect.scoped), ); + + for (const fixture of [ + { + name: "leaves mise npm-backend installations manual-only", + installSegments: ["mise", "installs", "npm-openai-codex", "0.110.0"], + npmOwned: false, + }, + { + name: "leaves mise tool aliases backed by npm manual-only", + installSegments: ["mise", "installs", "codex", "0.110.0"], + npmOwned: false, + }, + { + name: "keeps npm updates for globals in a mise Node installation", + installSegments: ["mise", "installs", "node", "24.0.0"], + npmOwned: true, + }, + { + name: "keeps npm updates for ordinary global installations", + installSegments: ["npm-global"], + npmOwned: true, + }, + ] as const) { + it.effect.skipIf(windowsHost)(fixture.name, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-installer-" }); + const installPath = NodePath.join(tempDir, ...fixture.installSegments); + const realBinaryPath = NodePath.join( + installPath, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + const binaryPath = NodePath.join(tempDir, "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(realBinaryPath), { recursive: true }); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString(realBinaryPath, "#!/bin/sh\n"); + yield* fs.chmod(realBinaryPath, 0o755); + yield* fs.symlink(realBinaryPath, binaryPath); + + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-installer"), + displayName: "Codex installer test", + enabled: false, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + const update = (yield* instance.snapshot.resolveMaintenance()).update; + if (fixture.npmOwned) { + expect(update).toMatchObject({ + executable: "npm", + args: [ + "install", + "-g", + "--prefix", + installPath, + "--allow-scripts=@openai/codex", + "@openai/codex@latest", + ], + }); + } else { + expect(update).toBeNull(); + } + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + } + + for (const layout of ["direct", "wrapper"] as const) { + it.effect.skipIf(windowsHost)(`leaves a mise ${layout} installation manual-only`, () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: `t3-codex-mise-${layout}-` }); + const binaryPath = + layout === "direct" + ? NodePath.join(tempDir, "mise", "installs", "codex", "0.110.0", "codex") + : NodePath.join(tempDir, "omarchy", "bin", "codex"); + yield* fs.makeDirectory(NodePath.dirname(binaryPath), { recursive: true }); + yield* fs.writeFileString( + binaryPath, + layout === "direct" + ? "#!/bin/sh\n" + : '#!/bin/sh\nmise use -g --quiet "codex" || exit 1\nexec mise x "codex" -- "codex" "$@"\n', + ); + yield* fs.chmod(binaryPath, 0o755); + + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make(`codex-mise-${layout}`), + displayName: "Codex mise test", + enabled: false, + environment: [], + config: { + ...CodexDriver.defaultConfig(), + binaryPath, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }); + + expect((yield* instance.snapshot.resolveMaintenance()).update).toBeNull(); + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, noSpawn), + Effect.scoped, + ), + ); + } }); diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index a09c94b61f60..d812f1ab7989 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -240,6 +240,12 @@ export function npmGlobalPrefixFromCommandPath( if (packageIndex < 0 || normalized.slice(0, packageIndex).includes("/node_modules/")) { return null; } + // Mise's npm backend uses a global-looking layout inside a tool version. + // Globals under its Node installation still belong to npm. + const miseTool = /\/mise\/installs\/([^/]+)\/[^/]+$/.exec(normalized.slice(0, packageIndex))?.[1]; + if (miseTool && miseTool !== "node") { + return null; + } return packageIndex === 0 ? "/" : slashPath.slice(0, packageIndex); } From f47a3fe90354a3dfa14276e3699541d552426276 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 01:33:25 -0400 Subject: [PATCH 004/320] fix(settings): share restart continuation across environments (#9933) --- .../features/settings/SettingsRouteScreen.tsx | 18 ++- .../src/environment/ServerEnvironment.ts | 1 + .../components/settings/SettingsPanels.tsx | 2 +- apps/web/src/hooks/useSettings.ts | 59 ++++++---- docs/user/updating.md | 11 +- .../src/state/sharedSettings.test.ts | 111 +++++++++++++++++- .../src/state/sharedSettings.ts | 42 +++++-- packages/contracts/src/environment.ts | 2 + 8 files changed, 204 insertions(+), 42 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 41c2076ac7b4..3bd25a20c8da 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -44,6 +44,7 @@ import { type ServerSettingsPatch, } from "@t3tools/contracts"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, supportsSharedSettingsSync, @@ -584,11 +585,13 @@ function AutoSettleSettingsRows() { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: reference.environmentId, primarySettings: referenceSettings, + primaryCapabilities: reference.serverConfig?.environment.capabilities, environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, + capabilities: environment.serverConfig?.environment.capabilities, })), }); @@ -652,11 +655,22 @@ function AutoSettleSettingsRows() { { - const patch = pickSharedServerSettings(referenceSettings); + const patch = pickSharedServerSettings( + referenceSettings, + reference.serverConfig?.environment.capabilities, + ); for (const mismatch of mismatches) { + const target = environments.find( + (candidate) => candidate.environmentId === mismatch.environmentId, + ); void updateSettings({ environmentId: mismatch.environmentId, - input: { patch }, + input: { + patch: filterSharedServerPatch( + patch, + target?.serverConfig?.environment.capabilities, + ), + }, }); } }} diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index cfecfc00c86d..eab723d7909a 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -219,6 +219,7 @@ export const make = Effect.gen(function* () { pullRequests: true, threadSettlement: true, threadAutoSettlement: true, + threadRestartContinuation: true, threadSnooze: true, environmentThemes: true, usageLimitSources: true, diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 8482e37440a4..e79464d1757c 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2439,7 +2439,7 @@ export function GeneralSettingsPanel() { { - const { environments } = useEnvironments(); - return useMemo( - () => - environments - .filter(supportsSharedSettingsSync) - .map((environment) => environment.environmentId), - [environments], - ); -} - /** * Returns an updater that routes each key to the correct backing store. * @@ -394,7 +383,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { serverEnvironment.updateSettings, "server settings update", ); - const sharedSettingsSyncTargetIds = useSharedSettingsSyncTargetIds(); + const { environments } = useEnvironments(); const updateSettings = useCallback( (patch: UnifiedSettingsPatch) => { const { serverPatch, clientPatch } = splitPatch(patch); @@ -402,11 +391,11 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { if (Object.keys(serverPatch).length > 0) { const { sharedPatch, localPatch } = splitSharedServerPatch(serverPatch); // Dropping the write silently leaves the control looking saved. - const warnUnsaved = () => + const warnUnsaved = (description = PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE) => toastManager.add({ type: "warning", title: "Setting not saved", - description: PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE, + description, }); if (Object.keys(localPatch).length > 0) { if (environmentId) { @@ -419,26 +408,38 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { } } if (Object.keys(sharedPatch).length > 0) { - const targets = new Set(sharedSettingsSyncTargetIds); + const targets = new Set( + environments.filter(supportsSharedSettingsSync).map((target) => target.environmentId), + ); if (environmentId) { targets.add(environmentId); } - if (targets.size === 0) { - warnUnsaved(); - } + let wroteToTarget = false; for (const targetId of targets) { + const target = environments.find((candidate) => candidate.environmentId === targetId); + const targetPatch = filterSharedServerPatch( + sharedPatch, + target?.serverConfig?.environment.capabilities, + ); + if (Object.keys(targetPatch).length === 0) continue; + wroteToTarget = true; void persistServerSettings({ environmentId: targetId, - input: { patch: sharedPatch }, + input: { patch: targetPatch }, }); } + if (!wroteToTarget) { + warnUnsaved( + targets.size > 0 ? "Update older servers to save this setting." : undefined, + ); + } } } if (Object.keys(clientPatch).length > 0) { persistClientSettingsPatch(clientPatch); } }, - [environmentId, persistServerSettings, sharedSettingsSyncTargetIds], + [environmentId, environments, persistServerSettings], ); return updateSettings; @@ -453,6 +454,7 @@ function useUpdateSettingsTarget(environmentId: EnvironmentId | null) { export function useSharedSettingsSync() { const primaryEnvironment = usePrimaryEnvironment(); const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const primaryCapabilities = primaryEnvironment?.serverConfig?.environment.capabilities; // Read the loaded config, not `primaryServerSettingsAtom`: that atom falls // back to defaults while the primary is disconnected, and "apply to all" // must never push defaults over real values. Same for a primary too old to @@ -472,28 +474,35 @@ export function useSharedSettingsSync() { findSharedSettingsMismatches({ primaryEnvironmentId, primarySettings, + primaryCapabilities, environments: environments.map((environment) => ({ environmentId: environment.environmentId, label: environment.label, syncEligible: supportsSharedSettingsSync(environment), settings: environment.serverConfig?.settings ?? null, + capabilities: environment.serverConfig?.environment.capabilities, })), }), - [environments, primaryEnvironmentId, primarySettings], + [environments, primaryEnvironmentId, primarySettings, primaryCapabilities], ); const applyToAll = useCallback(() => { if (primarySettings === null) { return; } - const patch = pickSharedServerSettings(primarySettings); + const patch = pickSharedServerSettings(primarySettings, primaryCapabilities); for (const mismatch of mismatches) { + const target = environments.find( + (candidate) => candidate.environmentId === mismatch.environmentId, + ); void persistServerSettings({ environmentId: mismatch.environmentId, - input: { patch }, + input: { + patch: filterSharedServerPatch(patch, target?.serverConfig?.environment.capabilities), + }, }); } - }, [mismatches, persistServerSettings, primarySettings]); + }, [environments, mismatches, persistServerSettings, primarySettings, primaryCapabilities]); return { mismatches, applyToAll }; } diff --git a/docs/user/updating.md b/docs/user/updating.md index 14500cbe4620..d72df7382f56 100644 --- a/docs/user/updating.md +++ b/docs/user/updating.md @@ -11,12 +11,15 @@ Server updates restart the connection and can interrupt active agents and terminal commands. Saved threads, settings, and project files remain. **Settings → General → Continue threads after restarts** is off by default. -Enable it for each environment to resume supported active threads after an -update, crash, or machine restart. T3 Code must start again on that machine; +Enable it to resume supported active threads after an update, crash, or machine +restart. Changes are saved to connected environments that support this setting; +update older servers first. If a supported environment was offline or has a +different value, use **Apply to all** in Settings after it connects. +T3 Code must start again on that machine; the setting does not enable automatic startup. Terminal commands may still be interrupted, and threads without saved provider resume state need a new message. -If you previously enabled continuation for updates, enable this environment -setting once to allow recovery without a connected client. +If you previously enabled continuation for updates, enable this setting once +to allow recovery without a connected client. ## Update a connected server diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 8c46a9f33579..8cf0e3bc7f08 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -2,6 +2,7 @@ import { DEFAULT_SERVER_SETTINGS, EnvironmentId } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import { + filterSharedServerPatch, findSharedSettingsMismatches, pickSharedServerSettings, splitSharedServerPatch, @@ -11,6 +12,7 @@ import { const primaryId = EnvironmentId.make("env-primary"); const laptopId = EnvironmentId.make("env-laptop"); const boxId = EnvironmentId.make("env-box"); +const restartCapabilities = { threadRestartContinuation: true }; describe("supportsSharedSettingsSync", () => { it("accepts only connected servers that advertise the shared-settings capability", () => { @@ -40,16 +42,24 @@ describe("splitSharedServerPatch", () => { const { sharedPatch, localPatch } = splitSharedServerPatch({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, + continueThreadsAfterServerUpdate: true, enableAgentBrowserAccess: false, }); - expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false }); + expect(sharedPatch).toEqual({ + sidebarAutoSettleAfterDays: 7, + sidebarAutoSettleOnMerge: false, + continueThreadsAfterServerUpdate: true, + }); expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); describe("pickSharedServerSettings", () => { it("returns only the shared keys", () => { - expect(Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS)).sort()).toEqual([ + expect( + Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), + ).toEqual([ + "continueThreadsAfterServerUpdate", "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", @@ -59,9 +69,106 @@ describe("pickSharedServerSettings", () => { }); }); +describe("filterSharedServerPatch", () => { + it.each([true, false])("preserves supported restart preference %s", (enabled) => { + const patch = { continueThreadsAfterServerUpdate: enabled, sidebarAutoSettleAfterDays: 7 }; + expect(filterSharedServerPatch(patch, restartCapabilities)).toEqual(patch); + }); + + it.each([undefined, {}, { threadRestartContinuation: false }])( + "omits only the unsupported restart preference with capabilities %j", + (capabilities) => { + expect( + filterSharedServerPatch( + { continueThreadsAfterServerUpdate: true, sidebarAutoSettleAfterDays: 7 }, + capabilities, + ), + ).toEqual({ sidebarAutoSettleAfterDays: 7 }); + expect(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, capabilities)).not.toHaveProperty( + "continueThreadsAfterServerUpdate", + ); + }, + ); +}); + describe("findSharedSettingsMismatches", () => { const primarySettings = { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: 7 }; + it.each([true, false])( + "detects remote restart continuation drift when the preference is %s", + (enabled) => { + const settings = { ...primarySettings, continueThreadsAfterServerUpdate: enabled }; + const remoteSettings = { ...settings, continueThreadsAfterServerUpdate: !enabled }; + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + settings: remoteSettings, + capabilities: restartCapabilities, + }; + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: settings, + primaryCapabilities: restartCapabilities, + environments: [environment], + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + expect( + findSharedSettingsMismatches({ + primaryEnvironmentId: primaryId, + primarySettings: settings, + primaryCapabilities: restartCapabilities, + environments: [ + { + ...environment, + settings: Object.assign( + {}, + remoteSettings, + pickSharedServerSettings(settings, restartCapabilities), + ), + }, + ], + }), + ).toEqual([]); + }, + ); + + it.each([ + [undefined, restartCapabilities], + [restartCapabilities, undefined], + [undefined, undefined], + ])( + "ignores restart drift unless both servers support it (%j, %j)", + (primaryCapabilities, capabilities) => { + const environment = { + environmentId: boxId, + label: "Remote Box", + syncEligible: true, + capabilities, + settings: { ...primarySettings, continueThreadsAfterServerUpdate: true }, + }; + const input = { + primaryEnvironmentId: primaryId, + primarySettings, + primaryCapabilities, + environments: [environment], + }; + expect(findSharedSettingsMismatches(input)).toEqual([]); + expect( + findSharedSettingsMismatches({ + ...input, + environments: [ + { + ...environment, + settings: { ...environment.settings, sidebarAutoSettleAfterDays: 14 }, + }, + ], + }), + ).toEqual([{ environmentId: boxId, label: "Remote Box" }]); + }, + ); + it("lists sync-eligible environments whose shared settings differ", () => { const mismatches = findSharedSettingsMismatches({ primaryEnvironmentId: primaryId, diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index f3236ef2035a..c578de9c7062 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -21,6 +21,7 @@ import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; /** Server keys that hold a user preference rather than machine config. */ export const SHARED_SERVER_SETTING_KEYS = [ + "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", "defaultThreadEnvMode", @@ -52,15 +53,27 @@ export function splitSharedServerPatch(patch: ServerSettingsPatch): { }; } -/** The shared subset of one environment's settings, as a patch that can be written elsewhere. */ -export function pickSharedServerSettings(settings: ServerSettings): ServerSettingsPatch { - return Struct.pick(settings, SHARED_SERVER_SETTING_KEYS); +/** Omit restart recovery on servers that cannot persist its preference. */ +export function filterSharedServerPatch( + patch: ServerSettingsPatch, + capabilities: Pick | undefined, +): ServerSettingsPatch { + return capabilities?.threadRestartContinuation === true + ? patch + : Struct.omit(patch, ["continueThreadsAfterServerUpdate"]); +} + +/** The shared subset supported by one environment. */ +export function pickSharedServerSettings( + settings: ServerSettings, + capabilities?: Pick, +): ServerSettingsPatch { + return filterSharedServerPatch(Struct.pick(settings, SHARED_SERVER_SETTING_KEYS), capabilities); } /** * Whether an environment can participate in shared-settings sync right now. - * Auto-settlement is the newest feature backed by a shared key, so a server - * advertising `threadAutoSettlement` can hold every shared key. + * Auto-settlement establishes baseline support; newer preferences are filtered separately. */ export function supportsSharedSettingsSync(environment: { readonly connection: { readonly phase: EnvironmentConnectionPhase }; @@ -81,12 +94,15 @@ export interface SharedSettingsEnvironment { readonly label: string; readonly syncEligible: boolean; readonly settings: ServerSettings | null; + readonly capabilities?: + | Pick + | undefined; } /** * Shared-settings sync targets whose values differ from the primary * environment's. Other environments are skipped: nothing can be read from or - * written to them, or their server cannot hold every shared key. With no + * written to them, or their server lacks baseline shared-settings support. With no * primary settings loaded there is nothing to compare against, so nothing is * reported. Callers must pass the real loaded settings, never a default * fallback, or "apply to all" would push defaults over real values. @@ -94,12 +110,18 @@ export interface SharedSettingsEnvironment { export function findSharedSettingsMismatches(input: { readonly primaryEnvironmentId: EnvironmentId | null; readonly primarySettings: ServerSettings | null; + readonly primaryCapabilities?: + | Pick + | undefined; readonly environments: ReadonlyArray; }): ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly label: string }> { if (input.primaryEnvironmentId === null || input.primarySettings === null) { return []; } - const expected = pickSharedServerSettings(input.primarySettings); + const primarySettings = pickSharedServerSettings( + input.primarySettings, + input.primaryCapabilities, + ); return input.environments.flatMap((environment) => { if ( environment.environmentId === input.primaryEnvironmentId || @@ -108,7 +130,11 @@ export function findSharedSettingsMismatches(input: { ) { return []; } - const actual = pickSharedServerSettings(environment.settings); + const expected = filterSharedServerPatch(primarySettings, environment.capabilities); + const actual = filterSharedServerPatch( + pickSharedServerSettings(environment.settings, environment.capabilities), + input.primaryCapabilities, + ); return Equal.equals(actual, expected) ? [] : [{ environmentId: environment.environmentId, label: environment.label }]; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 38347ebc92b9..6edcff005d86 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -94,6 +94,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadSettlement: Schema.optionalKey(Schema.Boolean), /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), + /** Server persists the opt-in for continuing interrupted threads after restarts. */ + threadRestartContinuation: Schema.optionalKey(Schema.Boolean), /** Server understands thread.snooze / thread.unsnooze commands. Same version-skew contract as threadSettlement. */ threadSnooze: Schema.optionalKey(Schema.Boolean), From 7a089b2b2449c5c146e1a7d554a31e6d55924d3f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:36:32 -0700 Subject: [PATCH 005/320] fix(server): capture turn checkpoints after all edits finish (#9841) --- .../Layers/CheckpointReactor.test.ts | 162 ++++++++++ .../orchestration/Layers/CheckpointReactor.ts | 107 +----- .../Layers/ProjectionPipeline.ts | 5 +- .../src/orchestration/projector.test.ts | 305 ++++++++++++------ apps/server/src/orchestration/projector.ts | 8 +- .../src/state/threadReducer.test.ts | 73 +++-- .../client-runtime/src/state/threadReducer.ts | 5 +- 7 files changed, 453 insertions(+), 212 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 2f2c4b30525b..390e61138f08 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -12,6 +12,7 @@ import { } from "@t3tools/contracts"; import { CommandId, + CheckpointRef, DEFAULT_PROVIDER_INTERACTION_MODE, EventId, MessageId, @@ -667,6 +668,167 @@ describe("CheckpointReactor", () => { }), ); + effectIt.effect.each(["turn.completed", "turn.aborted"] as const)( + "captures every edit after a mid-turn diff update on %s", + (terminalEventType) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ seedFilesystemCheckpoints: false }), + ); + const threadId = ThreadId.make("thread-1"); + const turnId = asTurnId("turn-1"); + const assistantMessageId = MessageId.make("assistant:mid-turn"); + const createdAt = "2026-01-01T00:00:00.000Z"; + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-mid-turn-running"), + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: turnId, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-mid-turn-start"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + }); + + NodeFS.writeFileSync(NodePath.join(harness.cwd, "early.ts"), "export const early = 1;\n"); + yield* harness.engine.dispatch({ + type: "thread.turn.diff.complete", + commandId: CommandId.make("cmd-mid-turn-diff"), + threadId, + turnId, + completedAt: createdAt, + checkpointRef: CheckpointRef.make("provider-diff:mid-turn"), + assistantMessageId, + status: "missing", + files: [], + checkpointTurnCount: 1, + createdAt, + }); + yield* Effect.promise(harness.drain); + + NodeFS.writeFileSync(NodePath.join(harness.cwd, "late.ts"), "export const late = 2;\n"); + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-mid-turn-settled"), + threadId, + session: { + threadId, + status: terminalEventType === "turn.aborted" ? "interrupted" : "ready", + providerName: "codex", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + createdAt, + }); + harness.provider.emit({ + eventId: EventId.make("evt-mid-turn-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId, + ...(terminalEventType === "turn.completed" + ? { type: "turn.completed", payload: { state: "completed" } } + : { type: "turn.aborted", payload: { reason: "Interrupted by user." } }), + }); + yield* Effect.promise(harness.drain); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 1))).toBe(true); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId, + checkpointTurnCount: 1, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "turn.processing.quiesced", + turnId, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect(thread?.checkpoints).toHaveLength(1); + expect(thread?.checkpoints[0]?.status).toBe("ready"); + expect(thread?.latestTurn?.state).toBe( + terminalEventType === "turn.aborted" ? "interrupted" : "completed", + ); + expect(thread?.checkpoints[0]?.assistantMessageId).toBe(assistantMessageId); + expect(thread?.checkpoints[0]?.files.map((file) => file.path)).toEqual([ + "early.ts", + "late.ts", + ]); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "late.ts"), + ).toBe("export const late = 2;\n"); + + const followUpTurnId = asTurnId("turn-2"); + harness.provider.emit({ + type: "turn.started", + eventId: EventId.make("evt-follow-up-start"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: followUpTurnId, + }); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-follow-up-complete"), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: followUpTurnId, + payload: { state: "completed" }, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId: followUpTurnId, + checkpointTurnCount: 2, + }); + const followUp = (yield* Effect.promise(harness.readModel)).threads.find( + (entry) => entry.id === threadId, + ); + expect( + followUp?.checkpoints.find((checkpoint) => checkpoint.turnId === followUpTurnId), + ).toMatchObject({ checkpointTurnCount: 2, files: [] }); + }), + ); + + it("does not capture an aborted turn without a matching start or active session", async () => { + const harness = await createHarness({ seedFilesystemCheckpoints: false }); + harness.provider.emit({ + type: "turn.aborted", + eventId: EventId.make("evt-untracked-abort"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-untracked"), + payload: { reason: "Interrupted before the turn started." }, + }); + await harness.drain(); + + const thread = (await harness.readModel()).threads.find((entry) => entry.id === "thread-1"); + expect(thread?.checkpoints).toEqual([]); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(false); + }); + it("refreshes local git status state on turn completion using the session cwd", async () => { const gitStatusRefreshCalls: string[] = []; const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 108abd5d06bb..f155a6ae365c 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -216,9 +216,7 @@ const make = Effect.gen(function* () { return cwd; }); - // Shared tail for both capture paths: creates the git checkpoint ref, diffs - // it against the previous turn, then dispatches the domain events to update - // the orchestration read model. + // Capture the completed turn's files, then publish its summary and receipts. const captureAndDispatchCheckpoint = Effect.fn("captureAndDispatchCheckpoint")(function* (input: { readonly threadId: ThreadId; readonly turnId: TurnId; @@ -353,9 +351,9 @@ const make = Effect.gen(function* () { }); }); - // Captures a real git checkpoint when a turn completes via a runtime event. + // Capture the files left by a completed or interrupted turn. const captureCheckpointFromTurnCompletion = Effect.fn("captureCheckpointFromTurnCompletion")( - function* (event: Extract) { + function* (event: Extract) { const turnId = toTurnId(event.turnId); if (!turnId) { return; @@ -412,75 +410,16 @@ const make = Effect.gen(function* () { thread, cwd: checkpointCwd, turnCount: nextTurnCount, - status: checkpointStatusFromRuntime(event.payload.state), - assistantMessageId: undefined, + status: + event.type === "turn.aborted" + ? "ready" + : checkpointStatusFromRuntime(event.payload.state), + assistantMessageId: existingPlaceholder?.assistantMessageId ?? undefined, createdAt: event.createdAt, }); }, ); - // Captures a real git checkpoint when a placeholder checkpoint (status "missing") - // is detected via a domain event. This replaces the placeholder with a real - // git-ref-based checkpoint. - // - // ProviderRuntimeIngestion creates placeholder checkpoints on turn.diff.updated - // events from the Codex runtime. This handler fires when the corresponding - // domain event arrives, allowing the reactor to capture the actual filesystem - // state into a git ref and dispatch a replacement checkpoint. - const captureCheckpointFromPlaceholder = Effect.fn("captureCheckpointFromPlaceholder")(function* ( - event: Extract, - ) { - const { threadId, turnId, checkpointTurnCount, status } = event.payload; - - // Only replace placeholders; skip events from our own real captures. - if (status !== "missing") { - return; - } - - const thread = yield* resolveThreadDetail(threadId); - if (!thread) { - yield* Effect.logWarning("checkpoint capture from placeholder skipped: thread not found", { - threadId, - }); - return; - } - - // If a real checkpoint already exists for this turn, skip. - if ( - thread.checkpoints.some( - (checkpoint) => checkpoint.turnId === turnId && checkpoint.status !== "missing", - ) - ) { - yield* Effect.logDebug( - "checkpoint capture from placeholder skipped: real checkpoint already exists", - { threadId, turnId }, - ); - return; - } - - const projects = yield* resolveThreadProjects(thread.projectId); - const checkpointCwd = yield* resolveCheckpointCwd({ - threadId, - thread, - projects, - preferSessionRuntime: true, - }); - if (!checkpointCwd) { - return; - } - - yield* captureAndDispatchCheckpoint({ - threadId, - turnId, - thread, - cwd: checkpointCwd, - turnCount: checkpointTurnCount, - status: "ready", - assistantMessageId: event.payload.assistantMessageId ?? undefined, - createdAt: event.payload.completedAt, - }); - }); - const ensurePreTurnBaselineFromTurnStart = Effect.fn("ensurePreTurnBaselineFromTurnStart")( function* (event: Extract) { const turnId = toTurnId(event.turnId); @@ -876,25 +815,6 @@ const make = Effect.gen(function* () { ); return; } - - // When ProviderRuntimeIngestion creates a placeholder checkpoint (status "missing") - // from a turn.diff.updated runtime event, capture the real git checkpoint to - // replace it. ProviderService broadcasts runtime events to each subscriber. - // This domain-event path also captures checkpoints from turn diff updates. - if (event.type === "thread.turn-diff-completed") { - yield* captureCheckpointFromPlaceholder(event).pipe( - Effect.catch((error) => - Effect.flatMap(nowIso, (createdAt) => - appendCaptureFailureActivity({ - threadId: event.payload.threadId, - turnId: event.payload.turnId, - detail: error.message, - createdAt, - }).pipe(Effect.catch(() => Effect.void)), - ), - ), - ); - } }); const processRuntimeEvent = Effect.fn("processRuntimeEvent")(function* ( @@ -939,7 +859,13 @@ const make = Effect.gen(function* () { pending.delete(event.threadId); yield* pullRequests.refreshAfterTurn; } - if (event.type === "turn.aborted") return; + if ( + event.type === "turn.aborted" && + !isTrackedTurn && + !sameId(thread?.session?.activeTurnId, turnId) + ) { + return; + } yield* captureCheckpointFromTurnCompletion(event).pipe( Effect.catch((error) => Effect.flatMap(nowIso, (createdAt) => @@ -987,8 +913,7 @@ const make = Effect.gen(function* () { if ( event.type !== "thread.turn-start-requested" && event.type !== "thread.message-sent" && - event.type !== "thread.checkpoint-revert-requested" && - event.type !== "thread.turn-diff-completed" + event.type !== "thread.checkpoint-revert-requested" ) { return Effect.void; } diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 77dfb5e3b88d..cd028d502238 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1573,7 +1573,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionTurnRepository.upsertByTurnId({ ...existingTurn.value, assistantMessageId: event.payload.assistantMessageId, - state: turnStillRunning ? existingTurn.value.state : nextState, + state: + turnStillRunning || existingTurn.value.state === "interrupted" + ? existingTurn.value.state + : nextState, checkpointTurnCount: event.payload.checkpointTurnCount, checkpointRef: event.payload.checkpointRef, checkpointStatus: event.payload.status, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index dad3d07370f9..4b37137936f4 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -7,6 +7,7 @@ import { type OrchestrationEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; +import { it as effectIt } from "@effect/vitest"; import { describe, expect, it } from "vite-plus/test"; import { createEmptyReadModel, projectEvent } from "./projector.ts"; @@ -239,110 +240,234 @@ describe("orchestration projector", () => { expect(next.threads).toEqual([]); }); - it("tracks latest turn id from session lifecycle events", async () => { - const createdAt = "2026-02-23T08:00:00.000Z"; - const startedAt = "2026-02-23T08:00:05.000Z"; - const model = createEmptyReadModel(createdAt); - - const afterCreate = await Effect.runPromise( - projectEvent( - model, - makeEvent({ - sequence: 1, - type: "thread.created", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: createdAt, - commandId: "cmd-create", - payload: { - threadId: "thread-1", - projectId: "project-1", - title: "demo", - modelSelection: { - provider: ProviderDriverKind.make("codex"), - model: "gpt-5.3-codex", - }, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - updatedAt: createdAt, - }, - }), - ), - ); + effectIt.effect.each([ + ["ready", "completed"], + ["interrupted", "interrupted"], + ] as const)( + "preserves the turn state after a %s session captures its checkpoint", + ([status, state]) => + Effect.gen(function* () { + const createdAt = "2026-02-23T08:00:00.000Z"; + const startedAt = "2026-02-23T08:00:05.000Z"; + const model = createEmptyReadModel(createdAt); - const settledAt = "2026-02-23T08:01:00.000Z"; - const [afterRunning, afterReady] = await Effect.runPromise( - Effect.flatMap( - projectEvent( - afterCreate, + const afterCreate = yield* projectEvent( + model, makeEvent({ - sequence: 2, - type: "thread.session-set", + sequence: 1, + type: "thread.created", aggregateKind: "thread", aggregateId: "thread-1", - occurredAt: startedAt, - commandId: "cmd-running", + occurredAt: createdAt, + commandId: "cmd-create", payload: { threadId: "thread-1", - session: { - threadId: "thread-1", - status: "running", - providerName: "codex", - providerSessionId: "session-1", - providerThreadId: "provider-thread-1", - runtimeMode: "approval-required", - activeTurnId: "turn-1", - lastError: null, - updatedAt: startedAt, + projectId: "project-1", + title: "demo", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, }, }), - ), - (running) => - Effect.map( - projectEvent( - running, - makeEvent({ - sequence: 3, - type: "thread.session-set", - aggregateKind: "thread", - aggregateId: "thread-1", - occurredAt: settledAt, - commandId: "cmd-ready", - payload: { + ); + + const settledAt = "2026-02-23T08:01:00.000Z"; + const [afterRunning, afterReady] = yield* Effect.flatMap( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: startedAt, + commandId: "cmd-running", + payload: { + threadId: "thread-1", + session: { threadId: "thread-1", - session: { + status: "running", + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "approval-required", + activeTurnId: "turn-1", + lastError: null, + updatedAt: startedAt, + }, + }, + }), + ), + (running) => + Effect.map( + projectEvent( + running, + makeEvent({ + sequence: 3, + type: "thread.session-set", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: settledAt, + commandId: "cmd-ready", + payload: { threadId: "thread-1", - status: "ready", - providerName: "codex", - providerSessionId: "session-1", - providerThreadId: "provider-thread-1", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: settledAt, + session: { + threadId: "thread-1", + status, + providerName: "codex", + providerSessionId: "session-1", + providerThreadId: "provider-thread-1", + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: settledAt, + }, }, - }, - }), + }), + ), + (ready) => [running, ready] as const, ), - (ready) => [running, ready] as const, - ), - ), - ); + ); - const thread = afterRunning.threads[0]; - expect(thread?.latestTurn?.turnId).toBe("turn-1"); - expect(thread?.session?.status).toBe("running"); - - // Leaving the "running" session status settles the running turn with the - // session timestamp as the turn end. - const settledThread = afterReady.threads[0]; - expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); - expect(settledThread?.latestTurn?.state).toBe("completed"); - expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); - }); + const thread = afterRunning.threads[0]; + expect(thread?.latestTurn?.turnId).toBe("turn-1"); + expect(thread?.session?.status).toBe("running"); + + // Leaving the "running" session status settles the running turn with the + // session timestamp as the turn end. + const settledThread = afterReady.threads[0]; + expect(settledThread?.latestTurn?.turnId).toBe("turn-1"); + expect(settledThread?.latestTurn?.state).toBe(state); + expect(settledThread?.latestTurn?.completedAt).toBe(settledAt); + + const captured = yield* projectEvent( + afterReady, + makeEvent({ + sequence: 4, + type: "thread.turn-diff-completed", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: settledAt, + commandId: "cmd-final-checkpoint", + payload: { + threadId: "thread-1", + turnId: "turn-1", + checkpointTurnCount: 1, + checkpointRef: "refs/t3/checkpoints/thread-1/turn/1", + status: "ready", + files: [], + assistantMessageId: "assistant:turn-1", + completedAt: settledAt, + }, + }), + ); + expect(captured.threads[0]?.latestTurn?.state).toBe(state); + expect(captured.threads[0]?.checkpoints[0]?.status).toBe("ready"); + }), + ); + + effectIt.effect.each([null, "ready", "interrupted", "stopped"] as const)( + "replaces a missing checkpoint without inventing interruption for a %s session", + (sessionStatus) => + Effect.gen(function* () { + const now = "2026-09-04T23:00:00.000Z"; + const threadId = "thread-placeholder"; + const event = (sequence: number, type: OrchestrationEvent["type"], payload: unknown) => + makeEvent({ + sequence, + type, + payload, + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: `placeholder-${sequence}`, + }); + let model = yield* projectEvent( + createEmptyReadModel(now), + event(1, "thread.created", { + threadId, + projectId: "project-1", + title: "Placeholder", + modelSelection: { instanceId: "codex", model: "test" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }), + ); + const checkpoint = { + threadId, + turnId: "turn-placeholder", + checkpointTurnCount: 1, + checkpointRef: "provider-diff:placeholder", + files: [], + assistantMessageId: "assistant:placeholder", + completedAt: now, + }; + if (sessionStatus === "interrupted" || sessionStatus === "stopped") { + model = yield* projectEvent( + model, + event(2, "thread.session-set", { + threadId, + session: { + threadId, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: "turn-placeholder", + lastError: null, + updatedAt: now, + }, + }), + ); + } + model = yield* projectEvent( + model, + event(3, "thread.turn-diff-completed", { + ...checkpoint, + status: "missing", + }), + ); + if (sessionStatus !== null) { + model = yield* projectEvent( + model, + event(4, "thread.session-set", { + threadId, + session: { + threadId, + status: sessionStatus, + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + } + model = yield* projectEvent( + model, + event(5, "thread.turn-diff-completed", { + ...checkpoint, + status: "ready", + checkpointRef: "refs/t3/checkpoints/thread-placeholder/turn/1", + }), + ); + expect(model.threads[0]?.latestTurn?.state).toBe( + sessionStatus === "interrupted" || sessionStatus === "stopped" + ? "interrupted" + : "completed", + ); + }), + ); it("updates canonical thread runtime mode from thread.runtime-mode-set", async () => { const createdAt = "2026-02-23T08:00:00.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 3cea194bbb44..a558e0ad7af8 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -64,7 +64,7 @@ function retainThreadActivities(activities: OrchestrationThread["activities"]) { function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { if (status === "error") return "error" as const; - if (status === "missing") return "interrupted" as const; + // Match SQL and client projections: a missing git ref is not an interruption. return "completed" as const; } @@ -746,7 +746,11 @@ export function projectEvent( ? thread.latestTurn : { turnId: payload.turnId, - state: checkpointStatusToLatestTurnState(payload.status), + state: + thread.latestTurn?.turnId === payload.turnId && + thread.latestTurn.state === "interrupted" + ? "interrupted" + : checkpointStatusToLatestTurnState(payload.status), requestedAt: thread.latestTurn?.turnId === payload.turnId ? thread.latestTurn.requestedAt diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 401980997663..9a9be9b4da09 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -1129,33 +1129,52 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.turn-diff-completed", () => { - it("adds a checkpoint and updates latestTurn", () => { - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 13, - occurredAt: "2026-04-01T12:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.turn-diff-completed", - payload: { - threadId: ThreadId.make("thread-1"), - turnId: TurnId.make("turn-1"), - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("ref-1"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("msg-3"), - completedAt: "2026-04-01T12:00:00.000Z", - }, - }); - - expect(result.kind).toBe("updated"); - if (result.kind === "updated") { - expect(result.thread.checkpoints).toHaveLength(1); - expect(result.thread.latestTurn?.turnId).toBe("turn-1"); - expect(result.thread.latestTurn?.state).toBe("completed"); - } - }); + it.each([null, "interrupted"] as const)( + "adds a checkpoint without replacing a %s turn outcome", + (previousState) => { + const result = applyThreadDetailEvent( + { + ...baseThread, + latestTurn: + previousState === null + ? null + : { + turnId: TurnId.make("turn-1"), + state: previousState, + requestedAt: "2026-04-01T11:00:00.000Z", + startedAt: "2026-04-01T11:00:00.000Z", + completedAt: "2026-04-01T12:00:00.000Z", + assistantMessageId: null, + }, + }, + { + ...baseEventFields, + sequence: 13, + occurredAt: "2026-04-01T12:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.turn-diff-completed", + payload: { + threadId: ThreadId.make("thread-1"), + turnId: TurnId.make("turn-1"), + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("ref-1"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("msg-3"), + completedAt: "2026-04-01T12:00:00.000Z", + }, + }, + ); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.checkpoints).toHaveLength(1); + expect(result.thread.latestTurn?.turnId).toBe("turn-1"); + expect(result.thread.latestTurn?.state).toBe(previousState ?? "completed"); + } + }, + ); }); describe("thread.reverted", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 690c74bdea0a..1de0b654c060 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -521,7 +521,10 @@ export function applyThreadDetailEvent( (thread.latestTurn === null || thread.latestTurn.turnId === event.payload.turnId) ? { turnId: event.payload.turnId, - state: checkpointStatusToTurnState(event.payload.status), + state: + thread.latestTurn?.state === "interrupted" + ? "interrupted" + : checkpointStatusToTurnState(event.payload.status), requestedAt: thread.latestTurn?.requestedAt ?? event.payload.completedAt, startedAt: thread.latestTurn?.startedAt ?? event.payload.completedAt, completedAt: event.payload.completedAt, From 2fa5ef4c7bf3aafabe98392d25be7eb86847ce8f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 22:56:36 -0700 Subject: [PATCH 006/320] fix(web): load file grammar before enabling edits (#9947) --- .../files/fileEditorLanguageReadiness.test.ts | 186 ++++++++++++++++++ patches/@pierre%2Fdiffs@1.3.0-beta.10.patch | 28 +++ pnpm-lock.yaml | 10 +- 3 files changed, 219 insertions(+), 5 deletions(-) create mode 100644 apps/web/src/components/files/fileEditorLanguageReadiness.test.ts diff --git a/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts new file mode 100644 index 000000000000..520c0fa82d0e --- /dev/null +++ b/apps/web/src/components/files/fileEditorLanguageReadiness.test.ts @@ -0,0 +1,186 @@ +import { + FileRenderer, + disposeHighlighter, + getSharedHighlighter, + type BaseCodeOptions, + type DiffsHighlighter, + type FileContents, + type HighlightedToken, +} from "@pierre/diffs"; +import { TextDocument } from "@pierre/diffs/editor"; +import { WorkerPoolManager, type WorkerRequest, type WorkerResponse } from "@pierre/diffs/worker"; +import * as NodeWorkerThreads from "node:worker_threads"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DocumentChange = NonNullable["applyEdits"]>>; +interface Tokenizer { + tokenize(change: DocumentChange): Map; + cleanUp(): void; +} + +const tokenizerUrl = new URL("./editor/tokenizer.js", import.meta.resolve("@pierre/diffs")); +const { EditorTokenizer } = (await import(/* @vite-ignore */ tokenizerUrl.href)) as { + EditorTokenizer: new (options: { + codeOptions: BaseCodeOptions; + highlighter: DiffsHighlighter; + textDocument: TextDocument; + setStyle: (style: string) => void; + onDeferTokenize: () => void; + }) => Tokenizer; +}; + +const workerModule = import.meta.resolve("@pierre/diffs/worker/worker.js"); +const options = { + theme: "pierre-dark", + themeType: "dark", + preferredHighlighter: "shiki-wasm", + useTokenTransformer: true, +} as const; +const source = "export const View = () =>
Ready
;"; +let pool: WorkerPoolManager; +let renderer: FileRenderer; +let terminationPromises: Promise[]; + +class WorkerTransport { + private readonly worker = new NodeWorkerThreads.Worker( + `const { parentPort, workerData } = require("node:worker_threads"); + globalThis.self = { + addEventListener(type, listener) { + if (type === "message") parentPort.on("message", data => listener({ data })); + if (type === "error") process.on("uncaughtException", listener); + } + }; + globalThis.postMessage = data => parentPort.postMessage(data); + import(workerData.moduleUrl);`, + { eval: true, workerData: { moduleUrl: workerModule }, execArgv: [] }, + ); + + addEventListener( + type: "message" | "error", + listener: (event: { data: WorkerResponse } | Error) => void, + ) { + if (type === "error") this.worker.on("error", listener); + else this.worker.on("message", (data: WorkerResponse) => listener({ data })); + } + + postMessage(message: WorkerRequest) { + this.worker.postMessage(message, []); + } + + terminate() { + terminationPromises.push(this.worker.terminate()); + } +} + +function firstEnter(highlighter: DiffsHighlighter, file: FileContents, language: string) { + const document = new TextDocument(file.name, file.contents, language); + const tokenizer = new EditorTokenizer({ + codeOptions: options, + highlighter, + textDocument: document, + setStyle: () => {}, + onDeferTokenize: () => {}, + }); + try { + const end = document.positionAt(file.contents.length); + const change = document.applyEdits([{ range: { start: end, end }, newText: "\n" }]); + expect(change).toBeDefined(); + // This is the synchronous first edit, before the tokenizer's debounced prebuild. + const dirtyLines = tokenizer.tokenize(change!); + expect([...dirtyLines.keys()]).toEqual([0, 1]); + expect(document.getText()).toBe(`${file.contents}\n`); + } finally { + tokenizer.cleanUp(); + } +} + +beforeEach(async () => { + terminationPromises = []; + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => + setImmediate(() => callback(0)), + ); + vi.stubGlobal("cancelAnimationFrame", clearImmediate); + vi.stubGlobal("window", { matchMedia: () => ({ matches: true }) }); + await disposeHighlighter(); + pool = new WorkerPoolManager( + // Adapt transport only. The installed Pierre worker resolves and highlights the file. + { workerFactory: () => new WorkerTransport() as unknown as globalThis.Worker, poolSize: 1 }, + options, + ); + await pool.initialize(); + renderer = new FileRenderer(options, undefined, pool); +}); + +afterEach(async () => { + renderer?.cleanUp(); + pool?.terminate(); + await Promise.all(terminationPromises); + await disposeHighlighter(); + vi.unstubAllGlobals(); +}); + +describe("editable file language readiness", () => { + it.each(["hydrate", "renderFile"] as const)( + "%s prepares the inferred language before the first edit of a worker-highlighted file", + async (method) => { + const file = { name: "cold.tsx", contents: source, cacheKey: "cold-tsx" }; + await pool.primeFileHighlightCache(file); + expect(pool.getFileResultCache(file)).toBeDefined(); + const mainHighlighter = await getSharedHighlighter({ + themes: ["pierre-dark"], + langs: ["text"], + }); + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + renderer[method](file); + // Read-only worker rendering must not load editor grammars on the main thread. + expect(mainHighlighter.getLoadedLanguages()).not.toContain("tsx"); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "tsx"); + }, + ); + + it.each(["hydrate", "renderFile"] as const)( + "%s respects an explicit language when the filename suggests plain text", + async (method) => { + const file: FileContents = { + name: "source.txt", + lang: "tsx", + contents: source, + cacheKey: "explicit-tsx", + }; + await pool.primeFileHighlightCache(file); + renderer[method](file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }, + ); + + it("loads a newly opened language after reusing a worker-backed renderer", async () => { + const previousFile: FileContents = { + name: "previous.ts", + contents: "export const value = 1;", + cacheKey: "previous-ts", + }; + await getSharedHighlighter({ themes: ["pierre-dark"], langs: ["typescript"] }); + renderer.renderFile(previousFile); + firstEnter(await renderer.initializeHighlighter(), previousFile, "typescript"); + const nextFile = { name: "next.tsx", contents: source, cacheKey: "next-tsx" }; + renderer.renderFile(nextFile); + firstEnter(await renderer.initializeHighlighter(), nextFile, "tsx"); + }); + + it("prepares a hydrated non-worker file even when its theme was already loaded", async () => { + renderer.cleanUp(); + renderer = new FileRenderer(options); + const file = { name: "local.tsx", contents: source, cacheKey: "local-tsx" }; + renderer.hydrate(file); + firstEnter(await renderer.initializeHighlighter(), file, "tsx"); + }); + + it("keeps plain text editable without loading an unrelated grammar", async () => { + const file = { name: "notes.txt", contents: "Plain text", cacheKey: "plain-text" }; + renderer.renderFile(file); + const highlighter = await renderer.initializeHighlighter(); + firstEnter(highlighter, file, "text"); + expect(highlighter.getLoadedLanguages()).not.toContain("tsx"); + }); +}); diff --git a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch index 3b558fe56552..0c9819145d3b 100644 --- a/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch +++ b/patches/@pierre%2Fdiffs@1.3.0-beta.10.patch @@ -63,6 +63,18 @@ index e9f62f5..af82a46 100644 diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js --- a/dist/renderers/FileRenderer.js +++ b/dist/renderers/FileRenderer.js +@@ -107,10 +107,10 @@ + result: massiveFile ? void 0 : cache?.result, + renderRange: void 0 + }; ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (this.renderCache.result == null && !massiveFile) this.workerManager.highlightFileAST(this, file); + } else if (this.highlighter == null) { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + this.initializeHighlighter(); + } + } @@ -163,6 +163,8 @@ if (this.renderCache == null) return; const { file, result } = this.renderCache; @@ -72,6 +84,22 @@ diff --git a/dist/renderers/FileRenderer.js b/dist/renderers/FileRenderer.js const lineCache = this.lineCache != null && isLineCacheForFile(this.lineCache, file) ? this.lineCache : void 0; for (const [line, tokens] of dirtyLines) { if (lineCache != null && line < lineCache.lines.length) { +@@ -268,6 +270,7 @@ + const forcePlainText = !hasContent || isFilePlainText(file) || isFileMassive(lines.length, this.getTokenizeMaxLength()); + const newContent = !areFilesEqual(file, this.renderCache.file); + const newRenderRange = !areRenderRangesEqual(this.renderCache.renderRange, renderRange); ++ this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + if (this.workerManager?.isWorkingPool() === true) { + if (forcePlainText || this.renderCache.result == null || !this.renderCache.highlighted && (newContent || newRenderRange)) { + this.renderCache.file = file; +@@ -278,7 +281,6 @@ + } + if (!forcePlainText && hasContent && (!this.renderCache.highlighted || forceHighlight)) this.workerManager.highlightFileAST(this, file); + } else { +- this.computedLang = file.lang ?? getFiletypeFromFileName(file.name); + const hasThemes = this.highlighter != null && areThemesAttached(options.theme); + const hasLangs = this.highlighter != null && areLanguagesAttached(this.computedLang); + const canHighlight = !forcePlainText && hasLangs; diff --git a/package.json b/package.json index ff61c90..1e170e5 100644 --- a/package.json diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e0978718463e..c32bc1044a95 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -92,7 +92,7 @@ patchedDependencies: '@expo/metro-config@57.0.12': 96f1a75347e6ea02dc4b7034ace815d8ee39e18b8166ebfb573d9e58328f0dc2 '@ff-labs/fff-node@0.9.4': ab9ff544009e1891cfe3930105862d3699007f38922a79f3c98d90018deca368 '@legendapp/list@3.3.5': 03ec41339cd915ecb9a774a6b90cc2197c29038f7db67c4d2e55cd3971e5be43 - '@pierre/diffs@1.3.0-beta.10': c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e + '@pierre/diffs@1.3.0-beta.10': c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4 '@react-native-ai/apple@0.12.0': 2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d '@react-native-menu/menu@2.0.0': f63d256bf6a97a873b5e628eb595bd6ef0075ddd5bdd890fc920f7a6024290dd '@react-navigation/native-stack@7.17.6': e667c3cef8c78bb9ff4882ee5bd23a432247b843060a9499eb5f07e9e2295552 @@ -245,7 +245,7 @@ importers: version: 1.9.1 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@react-native-ai/apple': specifier: 0.12.0 version: 0.12.0(patch_hash=2d09870c2848d185cb05b53ed823a46e12dba519324d8dd8e584e28731990f9d)(react-native@0.86.3(@babel/core@7.29.7)(@react-native/metro-config@0.86.3(@babel/core@7.29.7)(bufferutil@4.1.0)(utf-8-validate@6.0.6))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6)) @@ -586,7 +586,7 @@ importers: version: 1.8.0 '@pierre/diffs': specifier: 'catalog:' - version: 1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + version: 1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@pierre/trees': specifier: 1.0.0-beta.4 version: 1.0.0-beta.4(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -13613,7 +13613,7 @@ snapshots: tslib: 2.8.1 webcrypto-core: 1.9.2 - '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.2.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(shiki@4.2.0) @@ -13627,7 +13627,7 @@ snapshots: transitivePeerDependencies: - '@shikijs/themes' - '@pierre/diffs@1.3.0-beta.10(patch_hash=c087c47b657c44ddce2b0ec8237d675086414134e91829c4b4edd009ec86893e)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@pierre/diffs@1.3.0-beta.10(patch_hash=c2ea3a9821addf19641e88074a6d8896c62b0f7cfa762899b0e9e80ff5e8add4)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: '@pierre/theme': 1.1.0 '@pierre/theming': 0.0.2(@pierre/theme@1.1.0)(@shikijs/themes@4.3.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(shiki@4.2.0) From e5a87e8b9ca9db21e0291ddbd54438c5fe56b277 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:10:18 -0700 Subject: [PATCH 007/320] fix(web): deduplicate PR project filter choices (#9948) --- .../pullRequest/PullRequestListFilters.tsx | 6 +- .../pullRequestProjectFilter.logic.test.ts | 158 ++++++++++++++++++ .../pullRequestProjectFilter.logic.ts | 53 ++++++ apps/web/src/components/ui/menu.tsx | 20 ++- apps/web/src/routes/_chat.pull-requests.tsx | 26 +-- 5 files changed, 239 insertions(+), 24 deletions(-) create mode 100644 apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts create mode 100644 apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index e45a687e981d..f77e2b845082 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -37,6 +37,7 @@ import { MenuPopup, MenuRadioGroup, MenuRadioItem, + MenuRadioItemIndicator, MenuSeparator, MenuSub, MenuSubPopup, @@ -205,6 +206,7 @@ function PullRequestFilterRadioGroup({ {option.label} {option.unavailable ? · Unavailable : null} + ); @@ -455,8 +457,8 @@ export function PullRequestFiltersMenu({ readonly environmentId: EnvironmentId; readonly title: string; readonly workspaceRoot: string; - readonly faviconPath?: string | null; - readonly projectIcon?: ProjectIconOverride | null; + readonly faviconPath?: string | null | undefined; + readonly projectIcon?: ProjectIconOverride | null | undefined; }>; projectId: ProjectId | undefined; /** diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts new file mode 100644 index 000000000000..5c660118ed28 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.test.ts @@ -0,0 +1,158 @@ +import { EnvironmentId, ProjectId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { findScopedProject } from "./pullRequestList.logic"; +import { pullRequestFilterProjects } from "./pullRequestProjectFilter.logic"; + +const cups = EnvironmentId.make("env-cups"); +const nucbox = EnvironmentId.make("env-nucbox"); +const labels = new Map([ + [cups, "cups"], + [nucbox, "nucbox-1"], +]); + +function project( + id: string, + environmentId = nucbox, + canonicalKey: string | null = "github.com/pingdotgg/t3code", +) { + return { + id: ProjectId.make(id), + environmentId, + title: "t3code", + workspaceRoot: `/work/${id}`, + repositoryIdentity: canonicalKey === null ? null : { canonicalKey }, + faviconPath: `${id}/favicon.png`, + }; +} + +describe("pull request project filter choices", () => { + it("collapses three checkouts on one server without dropping another server's copy", () => { + const projects = [ + project("main"), + project("worktree-1"), + project("worktree-2"), + project("main", cups), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map(({ id, environmentId, title }) => ({ id, environmentId, title }))).toEqual([ + { id: "main", environmentId: cups, title: "t3code · cups" }, + { id: "main", environmentId: nucbox, title: "t3code · nucbox-1" }, + ]); + expect(choices[1]?.workspaceRoot).toBe("/work/main"); + expect(choices[1]?.faviconPath).toBe("main/favicon.png"); + }); + + it("keeps a saved worktree selection as the repository's only choice", () => { + const projects = [project("main"), project("worktree"), project("worktree", cups)]; + const selected = findScopedProject(projects, nucbox, "worktree"); + + const choices = pullRequestFilterProjects(projects, labels, selected); + + expect(choices.filter((choice) => choice.environmentId === nucbox)).toEqual([ + { ...projects[1], title: "t3code · nucbox-1" }, + ]); + expect(findScopedProject(choices, nucbox, "worktree")).toBeDefined(); + expect(findScopedProject(choices, nucbox, "main")).toBeUndefined(); + expect(findScopedProject(choices, cups, "worktree")).toBeDefined(); + }); + + it("matches canonical repositories regardless of casing", () => { + const main = project("main"); + const worktree = project("worktree", nucbox, "GitHub.com/PingDotGG/T3Code"); + + expect(pullRequestFilterProjects([main, worktree], labels)).toEqual([main]); + }); + + it("does not add a server suffix after duplicate checkouts have collapsed", () => { + const main = project("main"); + + expect(pullRequestFilterProjects([main, project("worktree")], labels)).toEqual([main]); + expect(main.title).toBe("t3code"); + }); + + it("distinguishes same-named repositories on one server by checkout path", () => { + const projects = [ + project("upstream"), + project("fork", nucbox, "github.com/juliusmarminge/t3code"), + ]; + + const choices = pullRequestFilterProjects(projects, labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/fork", + "t3code · nucbox-1 · /work/upstream", + ]); + }); + + it("keeps repositories on different hosts separate", () => { + const choices = pullRequestFilterProjects( + [project("github"), project("enterprise", nucbox, "git.example.com/pingdotgg/t3code")], + labels, + ); + + expect(choices.map((choice) => choice.id)).toEqual(["enterprise", "github"]); + }); + + it("does not merge projects whose repository identity is unknown", () => { + const choices = pullRequestFilterProjects( + [project("first", nucbox, null), project("second", nucbox, null)], + labels, + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/first", + "t3code · nucbox-1 · /work/second", + ]); + }); + + it("distinguishes servers with the same display name and checkout path", () => { + const first = project("main"); + const second = project("main", cups); + const repeatedLabels = new Map([ + [cups, "nucbox-1"], + [nucbox, "nucbox-1"], + ]); + + const choices = pullRequestFilterProjects([first, second], repeatedLabels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/main · env-cups", + "t3code · nucbox-1 · /work/main · env-nucbox", + ]); + }); + + it("uses the environment id when its label is unavailable", () => { + const choices = pullRequestFilterProjects( + [project("main"), project("remote", cups)], + new Map(), + ); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · env-cups", + "t3code · env-nucbox", + ]); + }); + + it("can distinguish unresolved project records that also share a checkout path", () => { + const first = project("first", nucbox, null); + const second = { ...project("second", nucbox, null), workspaceRoot: first.workspaceRoot }; + + const choices = pullRequestFilterProjects([first, second], labels); + + expect(choices.map((choice) => choice.title)).toEqual([ + "t3code · nucbox-1 · /work/first · env-nucbox · first", + "t3code · nucbox-1 · /work/first · env-nucbox · second", + ]); + }); + + it("leaves unrelated names unchanged and orders them alphabetically", () => { + const app = { ...project("app"), title: "Zebra" }; + const tools = { ...project("tools", nucbox, "github.com/acme/tools"), title: "Alpha" }; + + expect(pullRequestFilterProjects([app, tools], labels)).toEqual([tools, app]); + expect(pullRequestFilterProjects([], labels)).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts new file mode 100644 index 000000000000..5b214e3f37f6 --- /dev/null +++ b/apps/web/src/components/pullRequest/pullRequestProjectFilter.logic.ts @@ -0,0 +1,53 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +import type { AssignableProject } from "./pullRequestProjectAssignment.logic"; + +interface FilterProject extends AssignableProject { + readonly title: string; + readonly workspaceRoot: string; +} + +function distinguishTitles( + projects: ReadonlyArray, + suffix: (project: Project) => string, +) { + const counts = new Map(); + for (const project of projects) { + counts.set(project.title, (counts.get(project.title) ?? 0) + 1); + } + return projects.map((project) => + (counts.get(project.title) ?? 0) > 1 + ? { ...project, title: `${project.title} · ${suffix(project)}` } + : project, + ); +} + +/** One choice per repository per server, retaining the selected checkout for saved scopes. */ +export function pullRequestFilterProjects( + projects: ReadonlyArray, + environmentLabels: ReadonlyMap, + selectedProject?: Pick, +) { + const byRepository = new Map(); + for (const project of projects) { + const repository = project.repositoryIdentity?.canonicalKey?.toLowerCase(); + const key = JSON.stringify([ + project.environmentId, + repository ? ["repository", repository] : ["project", project.id], + ]); + const selected = + project.id === selectedProject?.id && project.environmentId === selectedProject.environmentId; + if (!byRepository.has(key) || selected) byRepository.set(key, project); + } + + const byServer = distinguishTitles( + [...byRepository.values()], + (project) => environmentLabels.get(project.environmentId) ?? project.environmentId, + ); + const byPath = distinguishTitles(byServer, (project) => project.workspaceRoot); + // Separate environments can share both their display name and their checkout path. + const byEnvironment = distinguishTitles(byPath, (project) => project.environmentId); + return distinguishTitles(byEnvironment, (project) => project.id).toSorted((left, right) => + left.title.localeCompare(right.title), + ); +} diff --git a/apps/web/src/components/ui/menu.tsx b/apps/web/src/components/ui/menu.tsx index d7892cb228ab..fbdcbc03480a 100644 --- a/apps/web/src/components/ui/menu.tsx +++ b/apps/web/src/components/ui/menu.tsx @@ -1,7 +1,7 @@ "use client"; import { Menu as MenuPrimitive } from "@base-ui/react/menu"; -import { ChevronRightIcon } from "lucide-react"; +import { CheckIcon, ChevronRightIcon } from "lucide-react"; import type * as React from "react"; import { cn } from "~/lib/utils"; @@ -177,6 +177,23 @@ function MenuRadioItem({ ); } +function MenuRadioItemIndicator({ + className, + children, + ...props +}: MenuPrimitive.RadioItemIndicator.Props) { + return ( + + {children ?? } + + ); +} + function MenuGroupLabel({ className, inset, @@ -300,6 +317,7 @@ export { MenuRadioGroup as DropdownMenuRadioGroup, MenuRadioItem, MenuRadioItem as DropdownMenuRadioItem, + MenuRadioItemIndicator, MenuGroupLabel, MenuGroupLabel as DropdownMenuLabel, MenuSeparator, diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index a9537423a7f0..37522ea3b0a2 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -86,6 +86,7 @@ import { writePullRequestListPreferences, } from "../components/pullRequest/pullRequestListPreferences"; import { assignProjectsToEnvironments } from "../components/pullRequest/pullRequestProjectAssignment.logic"; +import { pullRequestFilterProjects } from "../components/pullRequest/pullRequestProjectFilter.logic"; import { environmentMachineIcon } from "../components/EnvironmentMachineIcon"; import { PullRequestDetailPanel } from "../components/pullRequest/PullRequestDetailPanel"; import { @@ -356,27 +357,6 @@ function PullRequestsRouteView() { ), [environments], ); - const scopedProjects = useMemo(() => { - // Two machines can hold the same repository, so a title the workspace carries twice is told - // apart by the environment it lives on rather than left as two identical rows. - const titleCounts = new Map(); - for (const project of projects) { - titleCounts.set(project.title, (titleCounts.get(project.title) ?? 0) + 1); - } - return projects - .map((project) => ({ - id: project.id, - environmentId: project.environmentId, - title: - (titleCounts.get(project.title) ?? 0) > 1 - ? `${project.title} · ${environmentLabels.get(project.environmentId) ?? project.environmentId}` - : project.title, - workspaceRoot: project.workspaceRoot, - faviconPath: project.faviconPath ?? null, - projectIcon: project.projectIcon ?? null, - })) - .toSorted((left, right) => left.title.localeCompare(right.title)); - }, [environmentLabels, projects]); // The scope the URL asks for, once the environments have had their say about whether it exists. const scopedProjectId = useMemo( () => resolveProjectScope(search.projectId, projects, projectsKnown), @@ -386,6 +366,10 @@ function PullRequestsRouteView() { () => findScopedProject(projects, scopedEnvironmentId, scopedProjectId), [projects, scopedEnvironmentId, scopedProjectId], ); + const scopedProjects = useMemo( + () => pullRequestFilterProjects(projects, environmentLabels, scopedProject), + [environmentLabels, projects, scopedProject], + ); // A link from a thread or the sidebar only knows the repository, so the owning project is // resolved here; an explicit `projectId` in the URL still wins. From 07d2497db89014ccd71aa077fc809aff47e4af91 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:24:43 -0700 Subject: [PATCH 008/320] test(web): keep provider field readers private (#9952) --- .../settings/ProviderSettingsForm.test.ts | 14 -------------- .../components/settings/ProviderSettingsForm.tsx | 8 ++------ 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/apps/web/src/components/settings/ProviderSettingsForm.test.ts b/apps/web/src/components/settings/ProviderSettingsForm.test.ts index 7dc13fa4f25c..4bb8cf11bd4e 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.test.ts +++ b/apps/web/src/components/settings/ProviderSettingsForm.test.ts @@ -5,8 +5,6 @@ import { DRIVER_OPTION_BY_VALUE } from "./providerDriverMeta"; import { deriveProviderSettingsFields, nextProviderConfigWithFieldValue, - readProviderConfigBoolean, - readProviderConfigString, } from "./ProviderSettingsForm"; describe("ProviderSettingsForm helpers", () => { @@ -90,10 +88,6 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ forkOwned: 1 }); }); - it("reads non-string config values as blank strings", () => { - expect(readProviderConfigString({ binaryPath: 123 }, "binaryPath")).toBe(""); - }); - it("omits false boolean fields when clearWhenEmpty is omit", () => { const next = nextProviderConfigWithFieldValue( { forkOwned: 1, experimental: true }, @@ -156,12 +150,4 @@ describe("ProviderSettingsForm helpers", () => { expect(next).toEqual({ experimental: false }); }); - - it("reads non-boolean config values as false booleans", () => { - expect(readProviderConfigBoolean({ experimental: "true" }, "experimental")).toBe(false); - }); - - it("reads missing boolean config values from the supplied default", () => { - expect(readProviderConfigBoolean({}, "experimental", true)).toBe(true); - }); }); diff --git a/apps/web/src/components/settings/ProviderSettingsForm.tsx b/apps/web/src/components/settings/ProviderSettingsForm.tsx index 902fd408b54f..6d644aaf01c5 100644 --- a/apps/web/src/components/settings/ProviderSettingsForm.tsx +++ b/apps/web/src/components/settings/ProviderSettingsForm.tsx @@ -119,17 +119,13 @@ export function deriveProviderSettingsFields( }); } -export function readProviderConfigString(config: unknown, key: string): string { +function readProviderConfigString(config: unknown, key: string): string { if (config === null || typeof config !== "object") return ""; const value = (config as Record)[key]; return typeof value === "string" ? value : ""; } -export function readProviderConfigBoolean( - config: unknown, - key: string, - defaultValue = false, -): boolean { +function readProviderConfigBoolean(config: unknown, key: string, defaultValue = false): boolean { if (config === null || typeof config !== "object") return defaultValue; const value = (config as Record)[key]; return typeof value === "boolean" ? value : defaultValue; From a399473c30e6b451ec6348e52cbf9b4c4b07e505 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:25:59 -0700 Subject: [PATCH 009/320] test(mobile): keep composer selection helper private (#9953) --- .../src/features/threads/use-composer-command-menu.test.ts | 7 ------- .../src/features/threads/use-composer-command-menu.ts | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts index 5ae248b6cde8..4c92325f5fbd 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.test.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.test.ts @@ -13,16 +13,9 @@ vi.mock("../../state/use-atom-command", () => ({ import { buildComposerSlashCommandItems, - composerSelectionAtEnd, resolveComposerCommandSelection, } from "./use-composer-command-menu"; -describe("composerSelectionAtEnd", () => { - it("resets a changed draft owner to the new draft end", () => { - expect(composerSelectionAtEnd("queued task 🧪")).toEqual({ start: 14, end: 14 }); - }); -}); - describe("mobile slash commands", () => { const antigravity = { driver: ProviderDriverKind.make("antigravity"), 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 5b5b444cca74..5900acada910 100644 --- a/apps/mobile/src/features/threads/use-composer-command-menu.ts +++ b/apps/mobile/src/features/threads/use-composer-command-menu.ts @@ -27,7 +27,7 @@ import { matchesSlashSkillQuery } from "./composerSlashSkillSearch"; const WORKSPACE_SNAPSHOT_RETRY_COOLDOWN_MS = 10_000; -export function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { +function composerSelectionAtEnd(draftMessage: string): ComposerEditorSelection { return { start: draftMessage.length, end: draftMessage.length }; } From f530d7b618c12332774c0d79d3747c468369e648 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:27:52 -0700 Subject: [PATCH 010/320] test(client-runtime): keep scoped key implementation private (#9955) --- .../client-runtime/src/environment/knownEnvironment.test.ts | 3 --- packages/client-runtime/src/environment/scoped.ts | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/client-runtime/src/environment/knownEnvironment.test.ts b/packages/client-runtime/src/environment/knownEnvironment.test.ts index 66bbb1df7e91..032be152fdbc 100644 --- a/packages/client-runtime/src/environment/knownEnvironment.test.ts +++ b/packages/client-runtime/src/environment/knownEnvironment.test.ts @@ -6,7 +6,6 @@ import { parseScopedProjectKey, parseScopedThreadKey, scopedProjectKey, - scopedRefKey, scopedThreadKey, scopeProjectRef, scopeThreadRef, @@ -40,8 +39,6 @@ describe("scoped refs", () => { const threadRef = scopeThreadRef(environmentId, ThreadId.make("thread-1")); it("builds stable scoped project and thread keys", () => { - expect(scopedRefKey(projectRef)).toBe("environment-test:project-1"); - expect(scopedRefKey(threadRef)).toBe("environment-test:thread-1"); expect(scopedProjectKey(projectRef)).toBe("environment-test:project-1"); expect(scopedThreadKey(threadRef)).toBe("environment-test:thread-1"); }); diff --git a/packages/client-runtime/src/environment/scoped.ts b/packages/client-runtime/src/environment/scoped.ts index 354c548c02de..7894c7ba5329 100644 --- a/packages/client-runtime/src/environment/scoped.ts +++ b/packages/client-runtime/src/environment/scoped.ts @@ -22,7 +22,7 @@ export function scopeThreadRef( return { environmentId, threadId }; } -export function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { +function scopedRefKey(ref: ScopedProjectRef | ScopedThreadRef): string { const localId = "projectId" in ref ? ref.projectId : ref.threadId; return `${ref.environmentId}:${localId}`; } From 6b87ce3a0bd925edcbb26fbc6870a6ca81dead0b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:28:13 -0700 Subject: [PATCH 011/320] fix(web): reveal reselected diff files (#9951) --- .../components/diffs/DiffFileTree.test.tsx | 193 ++++++++++++++++++ .../web/src/components/diffs/DiffFileTree.tsx | 23 +++ 2 files changed, 216 insertions(+) create mode 100644 apps/web/src/components/diffs/DiffFileTree.test.tsx diff --git a/apps/web/src/components/diffs/DiffFileTree.test.tsx b/apps/web/src/components/diffs/DiffFileTree.test.tsx new file mode 100644 index 000000000000..3a97c60254ab --- /dev/null +++ b/apps/web/src/components/diffs/DiffFileTree.test.tsx @@ -0,0 +1,193 @@ +import type { CodeViewScrollTarget } from "@pierre/diffs"; +import type { FileTree as FileTreeModel } from "@pierre/trees"; +import { FileTree } from "@pierre/trees/react"; +import { act, type MouseEvent, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { DiffFileTree, type DiffFileTreeEntry } from "./DiffFileTree"; +import { useCodeViewFileReveal } from "./useCodeViewFileReveal"; + +vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" }) })); +// Tooltip positioning is unrelated to the tree's actual model and activation path. +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children: ReactNode }) => children, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: () => null, +})); + +const entries: DiffFileTreeEntry[] = [ + { path: "01-tall.ts", status: "modified" }, + { path: "02-short.ts", status: "modified" }, + { path: "03-medium.ts", status: "modified" }, +]; + +class TreeRow { + constructor(readonly path: string) {} + + getAttribute(name: string) { + return name === "data-item-path" ? this.path : null; + } +} + +describe("diff tree file activation", () => { + let renderer: ReactTestRenderer | undefined; + const targets: CodeViewScrollTarget[] = []; + const viewer = { + getInstance: () => viewer, + scrollTo: (target: CodeViewScrollTarget) => targets.push(target), + }; + + function Panel({ + files = entries, + selectedPath = null, + }: { + files?: DiffFileTreeEntry[]; + selectedPath?: string | null; + }) { + const reveal = useCodeViewFileReveal(viewer, "working-tree"); + return ( + reveal(`${path}\0${path}`)} + /> + ); + } + + const model = (): FileTreeModel => renderer!.root.findByType(FileTree).props.model; + + async function mount(props: Parameters[0] = {}) { + await act(async () => { + renderer = create(); + }); + } + + // Exercise T3's capture handler before the real Pierre model's selection transition. + // Only DOM hit testing is represented here; native pointer/keyboard dispatch and diff + // geometry are verified separately in the integrated client. + async function activate(path: string, modifiers: Partial> = {}) { + const event = { + button: 0, + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + defaultPrevented: false, + nativeEvent: { composedPath: () => [{}, new TreeRow(path), {}] }, + ...modifiers, + } as MouseEvent; + await act(async () => { + renderer!.root + .find((node) => String(node.type) === "file-tree-container") + .props.onClickCapture?.(event); + const tree = model(); + const item = tree.getItem(path)!; + if (event.ctrlKey || event.metaKey) { + item.toggleSelect(); + } else { + for (const selected of tree.getSelectedPaths()) { + if (selected !== path) tree.getItem(selected)?.deselect(); + } + item.select(); + } + item.focus(); + if ("toggle" in item && !event.ctrlKey && !event.metaKey && !event.shiftKey) item.toggle(); + }); + } + + beforeEach(() => { + targets.length = 0; + vi.useFakeTimers(); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("HTMLElement", TreeRow); + }); + + afterEach(async () => { + await act(async () => renderer?.unmount()); + renderer = undefined; + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("reissues the reveal when the sole selected file is activated again", async () => { + await mount(); + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + await activate("02-short.ts"); + expect(targets).toEqual([ + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + { type: "item", id: "02-short.ts\u000002-short.ts", align: "start" }, + ]); + }); + + it("reveals newly selected files once in either direction", async () => { + await mount(); + await activate("02-short.ts"); + await activate("01-tall.ts"); + await activate("03-medium.ts"); + expect(targets.map((target) => ("id" in target ? target.id : null))).toEqual( + ["02-short.ts", "01-tall.ts", "03-medium.ts"].map((path) => `${path}\0${path}`), + ); + }); + + it("keeps focus-only navigation separate from button activation", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.focus()); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + await activate("01-tall.ts", { detail: 0 }); + await activate("01-tall.ts", { detail: 0 }); + expect(targets).toHaveLength(3); + }); + + it.each(["ctrlKey", "metaKey"] as const)( + "does not reveal a selected file that a %s click deselects", + async (modifier) => { + await mount(); + await activate("02-short.ts"); + await activate("02-short.ts", { [modifier]: true }); + expect(model().getSelectedPaths()).toEqual([]); + expect(targets).toHaveLength(1); + }, + ); + + it("lets a click narrow multiple selected files without a second reveal", async () => { + await mount(); + await activate("02-short.ts"); + await act(async () => model().getItem("01-tall.ts")!.select()); + expect(model().getSelectedPaths()).toHaveLength(2); + targets.length = 0; + await activate("02-short.ts"); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toHaveLength(1); + }); + + it("leaves directory selection and expansion to the tree", async () => { + await mount({ files: [{ path: "src/app.ts", status: "modified" }] }); + const directory = model().getItem("src/")!; + if (!("isExpanded" in directory)) throw new Error("Expected the directory handle"); + expect(directory.isExpanded()).toBe(true); + await activate("src/"); + expect(directory.isExpanded()).toBe(false); + await activate("src/"); + expect(directory.isExpanded()).toBe(true); + expect(targets).toEqual([]); + }); + + it("does not echo controlled selection, but lets the reader activate it", async () => { + await mount({ selectedPath: "02-short.ts" }); + expect(model().getSelectedPaths()).toEqual(["02-short.ts"]); + expect(targets).toEqual([]); + await activate("02-short.ts"); + expect(targets).toHaveLength(1); + await act(async () => { + renderer!.update(); + }); + expect(model().getSelectedPaths()).toEqual(["03-medium.ts"]); + expect(targets).toHaveLength(1); + }); +}); diff --git a/apps/web/src/components/diffs/DiffFileTree.tsx b/apps/web/src/components/diffs/DiffFileTree.tsx index 3715b62ca15a..0d200853bcb4 100644 --- a/apps/web/src/components/diffs/DiffFileTree.tsx +++ b/apps/web/src/components/diffs/DiffFileTree.tsx @@ -177,6 +177,29 @@ export function DiffFileTree({ { + if ( + event.defaultPrevented || + event.button !== 0 || + event.ctrlKey || + event.metaKey || + event.shiftKey || + event.altKey + ) { + return; + } + // Pierre does not emit a selection change for its sole selected row. + // Read selection before the row handles the click so new selections reveal only once. + const selected = model.getSelectedPaths(); + const path = selected.length === 1 ? selected[0] : undefined; + if (!path || !filePathsRef.current.has(path)) return; + const clickedSelectedRow = event.nativeEvent + .composedPath() + .some( + (node) => node instanceof HTMLElement && node.getAttribute("data-item-path") === path, + ); + if (clickedSelectedRow) onSelectFileRef.current(path); + }} className="min-h-0 flex-1 overflow-hidden" style={pierreTreeStyle(resolvedTheme)} /> From eae770b125553275e1ccfe4aebd88df28e818aed Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:29:26 -0700 Subject: [PATCH 012/320] refactor(web): remove obsolete changed-files preview helpers (#9956) --- .../chat/changedFilesPresentation.test.ts | 69 ------------ .../chat/changedFilesPresentation.ts | 102 ------------------ 2 files changed, 171 deletions(-) delete mode 100644 apps/web/src/components/chat/changedFilesPresentation.test.ts delete mode 100644 apps/web/src/components/chat/changedFilesPresentation.ts diff --git a/apps/web/src/components/chat/changedFilesPresentation.test.ts b/apps/web/src/components/chat/changedFilesPresentation.test.ts deleted file mode 100644 index d13445b02260..000000000000 --- a/apps/web/src/components/chat/changedFilesPresentation.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { - changedFileName, - selectChangedFilePreview, - shouldAutoExpandChangedFiles, - summarizeChangedFileScopes, -} from "./changedFilesPresentation"; - -describe("changed-files presentation", () => { - it("auto-expands only small, low-churn latest changes", () => { - const smallFiles = [ - { path: "src/a.ts", kind: "modified", additions: 80, deletions: 20 }, - { path: "src/b.ts", kind: "modified", additions: 60, deletions: 20 }, - ]; - - expect(shouldAutoExpandChangedFiles(smallFiles, true)).toBe(true); - expect(shouldAutoExpandChangedFiles(smallFiles, false)).toBe(false); - expect( - shouldAutoExpandChangedFiles( - [{ path: "src/a.ts", kind: "modified", additions: 201, deletions: 0 }], - true, - ), - ).toBe(false); - expect( - shouldAutoExpandChangedFiles( - Array.from({ length: 6 }, (_, index) => ({ - path: `src/${index}.ts`, - kind: "modified", - additions: 1, - deletions: 0, - })), - true, - ), - ).toBe(false); - }); - - it("summarizes the most prominent top-level scopes", () => { - const files = [ - { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, - { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, - { path: "apps/server/src/index.ts", kind: "modified", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, - { path: "apps\\mobile\\App.tsx", kind: "modified", additions: 1, deletions: 0 }, - ]; - - expect(summarizeChangedFileScopes(files)).toEqual([ - { label: "apps", fileCount: 3 }, - { label: "root", fileCount: 1 }, - { label: "packages", fileCount: 1 }, - ]); - }); - - it("previews files across different scopes before filling from one scope", () => { - const files = [ - { path: "apps/web/src/App.tsx", kind: "modified", additions: 1, deletions: 0 }, - { path: "apps/web/src/App.test.tsx", kind: "modified", additions: 1, deletions: 0 }, - { path: "packages/shared/src/git.ts", kind: "modified", additions: 1, deletions: 0 }, - { path: "README.md", kind: "modified", additions: 1, deletions: 0 }, - ]; - - expect(selectChangedFilePreview(files).map((file) => file.path)).toEqual([ - "apps/web/src/App.tsx", - "packages/shared/src/git.ts", - "README.md", - ]); - expect(changedFileName("apps\\web\\src\\App.tsx")).toBe("App.tsx"); - }); -}); diff --git a/apps/web/src/components/chat/changedFilesPresentation.ts b/apps/web/src/components/chat/changedFilesPresentation.ts deleted file mode 100644 index bb3cac6c4b12..000000000000 --- a/apps/web/src/components/chat/changedFilesPresentation.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { type TurnDiffFileChange } from "../../types"; -import { summarizeTurnDiffStats } from "../../lib/turnDiffTree"; - -export const CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT = 5; -export const CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT = 200; -export const CHANGED_FILES_PREVIEW_FILE_LIMIT = 3; -export const CHANGED_FILES_PREVIEW_SCOPE_LIMIT = 4; - -export interface ChangedFilesScopeSummary { - readonly label: string; - readonly fileCount: number; -} - -function pathSegments(pathValue: string): string[] { - return pathValue - .replaceAll("\\", "/") - .split("/") - .filter((segment) => segment.length > 0); -} - -export function changedFileName(pathValue: string): string { - return pathSegments(pathValue).at(-1) ?? pathValue; -} - -function changedFileScope(pathValue: string): string { - const segments = pathSegments(pathValue); - return segments.length > 1 ? (segments[0] ?? "root") : "root"; -} - -export function shouldAutoExpandChangedFiles( - files: ReadonlyArray, - isLatestTurn: boolean, -): boolean { - if (!isLatestTurn || files.length > CHANGED_FILES_AUTO_EXPAND_FILE_LIMIT) { - return false; - } - const stat = summarizeTurnDiffStats(files); - return stat.additions + stat.deletions <= CHANGED_FILES_AUTO_EXPAND_LINE_LIMIT; -} - -export function summarizeChangedFileScopes( - files: ReadonlyArray, - limit = CHANGED_FILES_PREVIEW_SCOPE_LIMIT, -): ChangedFilesScopeSummary[] { - const scopes = new Map(); - files.forEach((file, index) => { - const label = changedFileScope(file.path); - const current = scopes.get(label); - scopes.set(label, { - fileCount: (current?.fileCount ?? 0) + 1, - firstIndex: current?.firstIndex ?? index, - }); - }); - - return Array.from(scopes, ([label, scope]) => ({ - label, - fileCount: scope.fileCount, - firstIndex: scope.firstIndex, - })) - .toSorted( - (left, right) => - right.fileCount - left.fileCount || - left.firstIndex - right.firstIndex || - left.label.localeCompare(right.label), - ) - .slice(0, limit) - .map(({ label, fileCount }) => ({ label, fileCount })); -} - -export function selectChangedFilePreview( - files: ReadonlyArray, - limit = CHANGED_FILES_PREVIEW_FILE_LIMIT, -): TurnDiffFileChange[] { - const selected: TurnDiffFileChange[] = []; - const selectedPaths = new Set(); - const selectedScopes = new Set(); - - for (const file of files) { - const scope = changedFileScope(file.path); - if (selectedScopes.has(scope)) { - continue; - } - selected.push(file); - selectedPaths.add(file.path); - selectedScopes.add(scope); - if (selected.length === limit) { - return selected; - } - } - - for (const file of files) { - if (selectedPaths.has(file.path)) { - continue; - } - selected.push(file); - if (selected.length === limit) { - break; - } - } - - return selected; -} From bc8584bf8e967ecc1cd215d42bd7a47faf51a862 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:36:03 -0700 Subject: [PATCH 013/320] fix(web): give the browser keybinding notice breathing room (#9964) Co-authored-by: Claude Fable 5 --- apps/web/src/components/settings/KeybindingsSettings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index b0beea24cc47..ad99328647af 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -1324,7 +1324,7 @@ function KeybindingsList(props: KeybindingsListProps) { /** Shown in the browser build only; the desktop app receives every shortcut. */ function BrowserKeybindingNotice() { return ( -
+
Some shortcuts may be claimed by the browser before T3 Code sees them. Use the desktop app From 126ea5c3bcf888df4a918438ae7d68a4a4a905f2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:52:51 -0700 Subject: [PATCH 014/320] chore: configure Knip workspace audits (#9958) --- knip.jsonc | 74 +++++++ package.json | 3 + pnpm-lock.yaml | 518 ++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 592 insertions(+), 3 deletions(-) create mode 100644 knip.jsonc diff --git a/knip.jsonc b/knip.jsonc new file mode 100644 index 000000000000..25706930133b --- /dev/null +++ b/knip.jsonc @@ -0,0 +1,74 @@ +{ + "$schema": "https://unpkg.com/knip@6/schema.json", + // These executables are supplied by the OS or installed separately from npm. + "ignoreBinaries": ["eas", "file", "mkfifo", "pkill", "pkg-config", "plutil", "sips"], + "workspaces": { + ".": { + // Keep vendored reference repositories outside the root project. + "project": ["*.{js,mjs,cjs,ts,mts,cts}", ".github/**/*.cjs"], + "entry": [".github/scripts/thread-transfer-report.cjs"], + "vite": { "entry": [".github/**/*.test.cjs"] }, + "vitest": { "entry": [".github/**/*.test.cjs"] }, + }, + "apps/server": { + // Vite+ pack entries and the launcher used by installed background services. + "entry": [ + "src/bin.ts!", + "src/service-launcher.ts!", + "scripts/cli.ts", + "src/provider/testFixtures/*.mjs", + ], + // Native msgpack acceleration and the Vite+ web build prerequisite. + "ignoreDependencies": ["msgpackr-extract", "@t3tools/web"], + }, + "apps/desktop": { + // Electron loads these bundles by filename rather than importing them. + "entry": [ + "src/main.ts!", + "src/preload.ts!", + "src/preview-pick-preload.ts!", + "src/preview-pip-preload.ts!", + "src/preview/Annotation.css!", + ], + // The injected runtime resolves Playwright by string; release tooling runs + // electron-builder from this workspace, outside its package scripts. + "ignoreDependencies": ["playwright-core", "electron-builder"], + }, + "apps/web": { + // Worktree setup invokes this directly from t3.json. + "entry": ["scripts/warm-dep-cache.ts"], + }, + "apps/mobile": { + // Expo loads local config plugins by string; Metro handles platform variants. + "entry": ["index.ts!", "plugins/*.cjs"], + // Fonts are configured as asset paths; Expo autolinks the native packages. + "ignoreDependencies": [ + "@expo-google-fonts/dm-sans", + "@t3tools/mobile-review-diff-native", + "@t3tools/mobile-terminal-native", + ], + }, + "apps/mobile/modules/t3-markdown-text": { + // React Native codegen discovers component specifications by filename. + "entry": ["src/*NativeComponent.ts!"], + }, + "infra/relay": { + "entry": ["alchemy.run.ts!", "src/persistence/schema.ts!"], + }, + "packages/*": { + // These workspace packages are private to this monorepo. + "includeEntryExports": true, + }, + "packages/effect-acp": { + "includeEntryExports": true, + "entry": ["test/fixtures/*.ts"], + // Generated upstream protocol definitions are retained as a complete set. + "ignoreIssues": { "src/_generated/**": ["exports", "types"] }, + }, + "packages/effect-codex-app-server": { + "includeEntryExports": true, + "entry": ["test/fixtures/*.ts"], + "ignoreIssues": { "src/_generated/**": ["exports", "types"] }, + }, + }, +} diff --git a/package.json b/package.json index 46913a435a8a..3efa732f9c28 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "typecheck": "vp run -r --concurrency-limit 2 typecheck", "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", + "knip": "knip", + "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", "test:resource-monitor": "cargo test --locked --manifest-path native/resource-monitor/Cargo.toml", @@ -49,6 +51,7 @@ "@oxlint/plugins": "^1.63.0", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", + "knip": "6.34.0", "vite-plus": "catalog:" }, "engines": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c32bc1044a95..a22a638cdee2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -124,6 +124,9 @@ importers: '@typescript/native-preview': specifier: 'catalog:' version: 7.0.0-dev.20260604.1 + knip: + specifier: 6.34.0 + version: 6.34.0 vite-plus: specifier: 'catalog:' version: 0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) @@ -2143,12 +2146,18 @@ packages: '@emnapi/core@1.11.1': resolution: {integrity: sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==} + '@emnapi/core@1.11.2': + resolution: {integrity: sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==} + '@emnapi/runtime@1.10.0': resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} '@emnapi/runtime@1.11.1': resolution: {integrity: sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==} + '@emnapi/runtime@1.11.2': + resolution: {integrity: sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==} + '@emnapi/wasi-threads@1.2.1': resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} @@ -3385,6 +3394,128 @@ packages: '@oslojs/encoding@1.1.0': resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + resolution: {integrity: sha512-fOtoGvIoirkvxQVw9J1WJPxz571XPgLsPf9uhRD+PJteUnvrJHMDmK9pw2yZEGGyismtRoEsp+JcXUdF/JDMDw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxc-parser/binding-android-arm64@0.147.0': + resolution: {integrity: sha512-emjQHOYJaomo4ykaXQ1EItunr/I94Nk01oqBmU4dSkKSTupIDx6OysVDf2e8Eytm77rb+4ZxzgElyWP7rcEX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxc-parser/binding-darwin-arm64@0.147.0': + resolution: {integrity: sha512-kXvBPJL7RmDPJ2mze/vXPPVQimCDtFr9OFLjf7dyhV5Dx64cgcXh9KKrA1sMWvCObvJll9CZZUO0FBlFwD0l6A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxc-parser/binding-darwin-x64@0.147.0': + resolution: {integrity: sha512-mgFF8pLU6R64LbT27lSrtVRspVC/3IcZ0qyIikzmi78Y3Ik2OPnlAHHI0UEBRcC3qmNgtjaef7zkFt7/uPxIcw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxc-parser/binding-freebsd-x64@0.147.0': + resolution: {integrity: sha512-v38aiF11qufOTBcCAKL4skgQf0zJ4NEvRlivq7B5kHrlyvjCLjvNrMtNWDTz1SDUL6/xVsJRmLDxv2e+Cp4oWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + resolution: {integrity: sha512-AeIiBbwUaP0H1+4/qGW9l5qHecS/+XA5iMuieVcGb1T+tyc2dVGspFW13BWk/XrLsiGP/CiDJTJqAPLLCzZHkw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + resolution: {integrity: sha512-/41MKPW4RgPY4DJco0NCF0RYX3IMZaVlRNMNzvhaxRavc7tN3Txm+qllZbh0aMRs0VHdgUlbI8TcAOiTai4TKg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + resolution: {integrity: sha512-bmpw/RPhVXgZbtb3xBDuwW5s8+LvZYdqcDSX/sP2ltL77aTio3DP/B5ZTwwgoJ6Mr9vJs4RrmgEKW9XkLNUU1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + resolution: {integrity: sha512-gd7VX/FDVOw6mjQcu45iIcp4QkgybgJwh3a0OFG2NxmPCj628mQWD96QGu1kK8+mZF9qK4b/gIEyC63vQoB7+Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + resolution: {integrity: sha512-HnAzcfki7dSUNHf510Q2NmbJlz8Ys7rn8l9l588Pkx0tYe1BHLZnmELIgqizJ4WPhHGSwN8Ce+B/menVxS3odA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + resolution: {integrity: sha512-qlkOL6wT44U+fT5s/+sR6Shx0OdwvQF83JyIPZUxG/ovqZF5/7atOtjH+JPZ5/7ATQLbFBBSmghcy/+2NVB/ew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + resolution: {integrity: sha512-DlefD7L7sMXs/3hIBH23Egk0phj8kG0SA81dVGOQ3S1ekjOlmTLH2E+F2Thwfh1slKx6aH+lNc5fQYAw0GU7/g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + resolution: {integrity: sha512-Xqpagk/031IvZ4svrk2FF01YEqM/iN3MJV3SVZadKg/CsGlDCGoREqKHXYnoV5+8SfGe/m6RM1szXFLusTu/Uw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + resolution: {integrity: sha512-QioQOeUbI4ATUr0S2z88uA3Cds2R3Mm5Ge7U8XNYtlTb2GJF3rWlcj70z0AJhhOlbdm0YgVjqPBldUNbFylDIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + resolution: {integrity: sha512-NXy1tv/OdC+pPTwf9RiCZWPK53V/Xq/2cjSnjOSyKopajdaDqMIkgtDY+jXZemp2e8px5FeWfY2L2LwhKZQovg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + resolution: {integrity: sha512-GpGWZ6oKz4bjCWW9Mz5pCaGPyk2Aaze6zEoaslIQqpSLtpx5pXj/ap5gUNb5Jn2LIbqWyjkGLX9yv3NMuNcBVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + resolution: {integrity: sha512-a8mlt7CC8z7LUdCfaxhff4kCd+vSjE+NEFL0cxA8ukfuSnvAto/pWTjytW4BuVLnQGcCVdHJwcRKOfs++H+tjw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + resolution: {integrity: sha512-M5ViVDBcFLnl2632AuuWuP35zEL5oikK1jTx8r3+902VEDeSNHxFZAB6RZyfZ7MU6Oi5QTOG8MmMkIeHsudSaw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + resolution: {integrity: sha512-DUaE13OwnUSlHpLZNcC/nuT10ivlWqc5EZgsfgXuAmWYw0r3nDxGeLD1zlGwYwIgVk1/ZMAxoXpV+05stvbHaA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@oxc-project/runtime@0.146.0': resolution: {integrity: sha512-lbXHIpZ1MmK6zuw5txlMdIZ2waLVUIU5Gnm3sEuwJOiqDfQfbtjeHscatmeBoxbv8+If9LFM6PGh/3DcDWYIYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3398,6 +3529,112 @@ packages: '@oxc-project/types@0.146.0': resolution: {integrity: sha512-XC0QsnnhVe7sLIWmYmdPw7x5P0h4W8vUU3Nv1ySgWXtvCz8NizoAEpGXA0sOYoJQV2Rl13LgURAHQ5cI5ILCSA==} + '@oxc-project/types@0.147.0': + resolution: {integrity: sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + resolution: {integrity: sha512-y09e0L0SRI2OA2tUIrjBgoV3eH5hvUKXNkJqXmNo5V2WxIjyC7I7aJfRLMEVpA8yi95f90gFDvO0VMgrDw+vwA==} + cpu: [arm] + os: [android] + + '@oxc-resolver/binding-android-arm64@11.24.2': + resolution: {integrity: sha512-cl4icWaZFnLdg8m6qtnh5rBMuGbxc/ptStFHLeCNwr+2cZjkjNwQu/jYRS0CHlnPecOJMpuS5M6/BH+0J/YkEg==} + cpu: [arm64] + os: [android] + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + resolution: {integrity: sha512-At29QEMF6HajbQvgY8K6OXnHD1x9rad74xBEfmCB6ZqCGsdq75aK7tOYcTbOanMy8qdIBrfL3SMr3p/lfSlb9w==} + cpu: [arm64] + os: [darwin] + + '@oxc-resolver/binding-darwin-x64@11.24.2': + resolution: {integrity: sha512-A5Kqr1EUj4oIL5CF4WRssq/o5P0Y11cwoFouMRmQ7YnC/A8V93nv1nb7aSU8HwcgmXropjLNkVTl4MN87cu28Q==} + cpu: [x64] + os: [darwin] + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + resolution: {integrity: sha512-R5xkRBRRz7ceH/P5Jrc6G7FmdUdgpLYyESFAUDVTNQ9K0sGPxcp4ljiwEwEqsvNcQ4sYbMRrWcHHBCu7ksAJVw==} + cpu: [x64] + os: [freebsd] + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + resolution: {integrity: sha512-k/RuYL4L/R58IBn3wT5ma3Wh4k62bp1eYCFRWCmMsasUOqL+H6sW0VGFadEzKWXFFlz+2uIMoeMk9ySSZJHgbg==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + resolution: {integrity: sha512-bnHAak3ujYfH5pKk4NieFNbvYvernfoQDgwLddbZ3OtMYrem87/qjlA+u+aKG0oZcqSLGCful/6/CEA+aeAgaA==} + cpu: [arm] + os: [linux] + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + resolution: {integrity: sha512-vDT3KHgzYp47gmtNOqL2VNhCyl5Zv643eyxm//A68J8DeUGXrvD1pZFiaT4jSfe+RInfnn1R2yVHye4enx6RnA==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + resolution: {integrity: sha512-+kMlQvbzfyEYtu5FcjE4p+ttBLpKW4d/AsAsuE69BxV6V4twZJeIQZFfD8gh/wqglY0MkPSezWXQH0jBV13MUw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + resolution: {integrity: sha512-shjfMhmZ3gq9fv/w7bi3PnZlgOPG+2QAOFf0BJF0EgBSIGZ6PMLN2zbGEblTUYB/NKVDRyYhE2ff3dJ1QqNPkA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + resolution: {integrity: sha512-zGelwFR5oRo+b69k8Lrzun86DyUHzfKN6cnjbR9l7Z7NIRznOE/2ZvPa1IUKqAL2PzAXOdwkfVqNvO1H2RlpAw==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + resolution: {integrity: sha512-qxZ1SWCXJY0eyhAlP6Lmo9F2Nrtx7EkYj9oCgL8apDPCwXwCEDA2U697bbT81JIc2IrVjxO4KX6WU2N+oN9Z4w==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + resolution: {integrity: sha512-sGCecF3cx2DFlH4t/z7ApnOnXqN48p5p5mlHDEnHTAukQa2P+qMVE4CwyWE9W+q/m3QJ7kKfGrIjax31f44oFQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + resolution: {integrity: sha512-k/VlMMcSzMlahb3/fENM4rTlsJ0s3fFROA0KXPBmKggqmTSaE383sl8F3KCOXPLmVsYfW6hCitMhXCEtNeZxxg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + resolution: {integrity: sha512-8hbnZyNi97b/8wapYaIF9+t9GmZKBW2vunaOc3h9HGJptH7b7XpvZqOTBSm/MpTjr7H497BlgOaSfLUdhmy2bw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + resolution: {integrity: sha512-MvyGik3a6pVgZ0t/kWlbmFxFLmXQJwgLsY2eYFHLpy0wGwRbfzeIGgDwQ3kXqE30z+kSXennRkCrT7TUvkptNg==} + cpu: [arm64] + os: [openharmony] + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + resolution: {integrity: sha512-vHcssMPwO08RTvj/c0iOBz90attxyG3wQJ0dTcyEQK43LRpcdLWZlV5feBhv6Isn6ahbQIzHbCgfa81+RiML0Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + resolution: {integrity: sha512-uokJqro2iBqkFvJdKQLP7d8/BUmFwESQFVmIJUQKj1Xn1a/LysJoe1vmeECLF5b3jsV8CAL5sEMJXX6SdK9Nhg==} + cpu: [arm64] + os: [win32] + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + resolution: {integrity: sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw==} + cpu: [x64] + os: [win32] + '@oxfmt/binding-android-arm-eabi@0.64.0': resolution: {integrity: sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6981,6 +7218,9 @@ packages: fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} + fd-package-json@2.0.0: + resolution: {integrity: sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==} + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -7043,6 +7283,11 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formatly@0.7.0: + resolution: {integrity: sha512-7CXJtIIA0zy/u12StsYk25qVKxvdLA2ep2sTNxK3ov0mGNIIDqIvAXDSgTnAfDJFsPfWjuz0WjfYSdpvnLA5Tg==} + engines: {node: '>=18.3.0'} + hasBin: true + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -7124,6 +7369,9 @@ packages: get-tsconfig@4.14.0: resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.3: + resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} + get-tsconfig@5.0.0-beta.4: resolution: {integrity: sha512-7nF7C9fIPFEMHgEMEfgIlO9wDdZ8CyHw27rWciFZfHvHDReIiPhsYuzPRXsfvBCqFy1l8RRyyWV7QLM+ZhUJsQ==} engines: {node: '>=20.20.0'} @@ -7624,6 +7872,11 @@ packages: resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} engines: {node: '>=6'} + knip@6.34.0: + resolution: {integrity: sha512-bbHIrnGspYwe4EBPjjx+lvkUor0F2qfKQc5BPzPI4SOAImYA+k2ueVpIcF7d/W1LEVT7XoJUPt6zELeGZhXBgA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + kubernetes-types@1.30.0: resolution: {integrity: sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==} @@ -8496,6 +8749,13 @@ packages: outvariant@1.4.3: resolution: {integrity: sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==} + oxc-parser@0.147.0: + resolution: {integrity: sha512-5xaug6t7GfV3BO5Iv+xHW1rmQkDEQ3BEu3L8g3InsvWO5i8CYGc4tCZ2X985QcwWNycFJam+aOns6Nr2XAThTA==} + engines: {node: ^20.19.0 || >=22.12.0} + + oxc-resolver@11.24.2: + resolution: {integrity: sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw==} + oxfmt@0.64.0: resolution: {integrity: sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -8561,6 +8821,9 @@ packages: package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + package-manager-detector@1.8.0: + resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} + pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -8703,6 +8966,10 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} @@ -9498,6 +9765,10 @@ packages: resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==} engines: {node: '>= 18'} + smol-toml@1.8.0: + resolution: {integrity: sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -9617,6 +9888,10 @@ packages: resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} + strip-json-comments@5.0.3: + resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} + engines: {node: '>=14.16'} + strnum@2.3.0: resolution: {integrity: sha512-ums3KNd42PGyx5xaoVTO1mjU1bH3NpY4vsrVlnv9PNGqQj8wd7rJ6nEypLrJ7z5vxK5RP0yMLo6J/Gsm62DI5Q==} @@ -9849,6 +10124,10 @@ packages: ultrahtml@1.6.0: resolution: {integrity: sha512-R9fBn90VTJrqqLDwyMph+HGne8eqY1iPfYhPzZrvKpIfwkWZbcYlfpsb8B9dTvBfpy1/hqAD7Wi8EKfP9e8zdw==} + unbash@4.0.11: + resolution: {integrity: sha512-FoSOKV7NEofQSkAefMVHam4ZPKYMxjAydxiV72UFEDNV/YofxjGfiZ2A9pZjdL/lRJzTjcu4PABo1JYJX8N5iQ==} + engines: {node: '>=14'} + uncrypto@0.1.3: resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} @@ -10275,6 +10554,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + walk-up-path@4.0.0: + resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} + engines: {node: 20 || >=22} + walker@1.0.8: resolution: {integrity: sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ==} @@ -10622,10 +10905,10 @@ snapshots: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 js-yaml: 4.2.0 - picomatch: 4.0.4 + picomatch: 4.0.7 retext-smartypants: 6.2.0 shiki: 4.2.0 - smol-toml: 1.7.0 + smol-toml: 1.8.0 unified: 11.0.5 '@astrojs/language-server@2.16.10(prettier@3.8.3)(typescript@6.0.3)': @@ -12000,6 +12283,12 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/core@1.11.2': + dependencies: + '@emnapi/wasi-threads': 1.2.2 + tslib: 2.8.1 + optional: true + '@emnapi/runtime@1.10.0': dependencies: tslib: 2.8.1 @@ -12010,6 +12299,11 @@ snapshots: tslib: 2.8.1 optional: true + '@emnapi/runtime@1.11.2': + dependencies: + tslib: 2.8.1 + optional: true + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 @@ -12528,7 +12822,7 @@ snapshots: hermes-parser: 0.36.1 jsc-safe-url: 0.2.4 lightningcss: 1.33.0 - picomatch: 4.0.4 + picomatch: 4.0.7 postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: @@ -13329,6 +13623,13 @@ snapshots: '@tybys/wasm-util': 0.10.3 optional: true + '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2)': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@tybys/wasm-util': 0.10.3 + optional: true + '@neon-rs/load@0.0.4': {} '@noble/curves@1.9.1': @@ -13446,6 +13747,63 @@ snapshots: '@oslojs/encoding@1.1.0': {} + '@oxc-parser/binding-android-arm-eabi@0.147.0': + optional: true + + '@oxc-parser/binding-android-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-darwin-x64@0.147.0': + optional: true + + '@oxc-parser/binding-freebsd-x64@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-gnueabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm-musleabihf@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-arm64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-ppc64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-riscv64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-linux-s390x-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-gnu@0.147.0': + optional: true + + '@oxc-parser/binding-linux-x64-musl@0.147.0': + optional: true + + '@oxc-parser/binding-openharmony-arm64@0.147.0': + optional: true + + '@oxc-parser/binding-win32-arm64-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-ia32-msvc@0.147.0': + optional: true + + '@oxc-parser/binding-win32-x64-msvc@0.147.0': + optional: true + '@oxc-project/runtime@0.146.0': {} '@oxc-project/types@0.127.0': @@ -13455,6 +13813,69 @@ snapshots: '@oxc-project/types@0.146.0': {} + '@oxc-project/types@0.147.0': {} + + '@oxc-resolver/binding-android-arm-eabi@11.24.2': + optional: true + + '@oxc-resolver/binding-android-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-darwin-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-freebsd-x64@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-gnueabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm-musleabihf@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-arm64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-ppc64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-riscv64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-s390x-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-gnu@11.24.2': + optional: true + + '@oxc-resolver/binding-linux-x64-musl@11.24.2': + optional: true + + '@oxc-resolver/binding-openharmony-arm64@11.24.2': + optional: true + + '@oxc-resolver/binding-wasm32-wasi@11.24.2': + dependencies: + '@emnapi/core': 1.11.2 + '@emnapi/runtime': 1.11.2 + '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.2)(@emnapi/runtime@1.11.2) + optional: true + + '@oxc-resolver/binding-win32-arm64-msvc@11.24.2': + optional: true + + '@oxc-resolver/binding-win32-x64-msvc@11.24.2': + optional: true + '@oxfmt/binding-android-arm-eabi@0.64.0': optional: true @@ -17064,10 +17485,18 @@ snapshots: dependencies: bser: 2.1.1 + fd-package-json@2.0.0: + dependencies: + walk-up-path: 4.0.0 + fdir@6.5.0(picomatch@4.0.4): optionalDependencies: picomatch: 4.0.4 + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + fetch-nodeshim@0.4.10: {} ffi-rs@1.3.2: @@ -17145,6 +17574,11 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 + formatly@0.7.0: + dependencies: + fd-package-json: 2.0.0 + package-manager-detector: 1.8.0 + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -17233,6 +17667,10 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 + get-tsconfig@4.14.3: + dependencies: + resolve-pkg-maps: 1.0.0 + get-tsconfig@5.0.0-beta.4: dependencies: resolve-pkg-maps: 1.0.0 @@ -17801,6 +18239,22 @@ snapshots: kleur@4.1.5: {} + knip@6.34.0: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + formatly: 0.7.0 + get-tsconfig: 4.14.3 + jiti: 2.7.0 + oxc-parser: 0.147.0 + oxc-resolver: 11.24.2 + picomatch: 4.0.7 + smol-toml: 1.8.0 + strip-json-comments: 5.0.3 + tinyglobby: 0.2.17 + unbash: 4.0.11 + yaml: 2.9.0 + zod: 4.4.3 + kubernetes-types@1.30.0: {} lan-network@0.2.1: {} @@ -19083,6 +19537,52 @@ snapshots: outvariant@1.4.3: optional: true + oxc-parser@0.147.0: + dependencies: + '@oxc-project/types': 0.147.0 + optionalDependencies: + '@oxc-parser/binding-android-arm-eabi': 0.147.0 + '@oxc-parser/binding-android-arm64': 0.147.0 + '@oxc-parser/binding-darwin-arm64': 0.147.0 + '@oxc-parser/binding-darwin-x64': 0.147.0 + '@oxc-parser/binding-freebsd-x64': 0.147.0 + '@oxc-parser/binding-linux-arm-gnueabihf': 0.147.0 + '@oxc-parser/binding-linux-arm-musleabihf': 0.147.0 + '@oxc-parser/binding-linux-arm64-gnu': 0.147.0 + '@oxc-parser/binding-linux-arm64-musl': 0.147.0 + '@oxc-parser/binding-linux-ppc64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-gnu': 0.147.0 + '@oxc-parser/binding-linux-riscv64-musl': 0.147.0 + '@oxc-parser/binding-linux-s390x-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-gnu': 0.147.0 + '@oxc-parser/binding-linux-x64-musl': 0.147.0 + '@oxc-parser/binding-openharmony-arm64': 0.147.0 + '@oxc-parser/binding-win32-arm64-msvc': 0.147.0 + '@oxc-parser/binding-win32-ia32-msvc': 0.147.0 + '@oxc-parser/binding-win32-x64-msvc': 0.147.0 + + oxc-resolver@11.24.2: + optionalDependencies: + '@oxc-resolver/binding-android-arm-eabi': 11.24.2 + '@oxc-resolver/binding-android-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-arm64': 11.24.2 + '@oxc-resolver/binding-darwin-x64': 11.24.2 + '@oxc-resolver/binding-freebsd-x64': 11.24.2 + '@oxc-resolver/binding-linux-arm-gnueabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm-musleabihf': 11.24.2 + '@oxc-resolver/binding-linux-arm64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-arm64-musl': 11.24.2 + '@oxc-resolver/binding-linux-ppc64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-riscv64-musl': 11.24.2 + '@oxc-resolver/binding-linux-s390x-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-gnu': 11.24.2 + '@oxc-resolver/binding-linux-x64-musl': 11.24.2 + '@oxc-resolver/binding-openharmony-arm64': 11.24.2 + '@oxc-resolver/binding-wasm32-wasi': 11.24.2 + '@oxc-resolver/binding-win32-arm64-msvc': 11.24.2 + '@oxc-resolver/binding-win32-x64-msvc': 11.24.2 + oxfmt@0.64.0(vite-plus@0.3.0(@types/node@24.12.4)(bufferutil@4.1.0)(esbuild@0.28.1)(jiti@2.7.0)(msw@2.12.11(@types/node@24.12.4)(typescript@6.0.3))(terser@5.48.0)(typescript@6.0.3)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0)): dependencies: tinypool: 2.1.0 @@ -19170,6 +19670,8 @@ snapshots: package-manager-detector@1.6.0: {} + package-manager-detector@1.8.0: {} + pako@1.0.11: {} parse-entities@4.0.2: @@ -19308,6 +19810,8 @@ snapshots: picomatch@4.0.4: {} + picomatch@4.0.7: {} + pkce-challenge@5.0.1: {} pkg-up@3.1.0: @@ -20403,6 +20907,8 @@ snapshots: smol-toml@1.7.0: {} + smol-toml@1.8.0: {} + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -20505,6 +21011,8 @@ snapshots: dependencies: ansi-regex: 6.2.2 + strip-json-comments@5.0.3: {} + strnum@2.3.0: {} structured-headers@0.4.1: {} @@ -20721,6 +21229,8 @@ snapshots: ultrahtml@1.6.0: {} + unbash@4.0.11: {} + uncrypto@0.1.3: {} undici-types@7.16.0: {} @@ -21138,6 +21648,8 @@ snapshots: vscode-uri@3.1.0: {} + walk-up-path@4.0.0: {} + walker@1.0.8: dependencies: makeerror: 1.0.12 From 4a42fc62e15018973b0088667d037b2a35c4c261 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:52:52 -0700 Subject: [PATCH 015/320] refactor(web): prune unused UI and provider code (#9959) --- apps/web/src/components/color-selector.tsx | 101 --------------------- apps/web/src/modelSelection.ts | 4 +- apps/web/src/providerInstances.ts | 2 +- apps/web/src/providerModels.ts | 9 +- apps/web/src/session-logic.ts | 38 -------- 5 files changed, 4 insertions(+), 150 deletions(-) delete mode 100644 apps/web/src/components/color-selector.tsx diff --git a/apps/web/src/components/color-selector.tsx b/apps/web/src/components/color-selector.tsx deleted file mode 100644 index d6898b009eeb..000000000000 --- a/apps/web/src/components/color-selector.tsx +++ /dev/null @@ -1,101 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { cn } from "~/lib/utils"; - -interface ColorSelectorProps { - colors: string[]; - size?: "default" | "sm" | "lg"; - defaultValue: string; - name?: string; - onColorSelect?: (color: string) => void; - className?: string; -} - -const colorMap = { - default: "var(--contrast-foreground)", - red: "var(--color-red-500)", - green: "var(--color-green-500)", - blue: "var(--color-blue-500)", - yellow: "var(--color-yellow-500)", - purple: "var(--color-purple-500)", - pink: "var(--color-pink-500)", - indigo: "var(--color-indigo-500)", - orange: "var(--color-orange-500)", - teal: "var(--color-teal-500)", - cyan: "var(--color-cyan-500)", - lime: "var(--color-lime-500)", - emerald: "var(--color-emerald-500)", - violet: "var(--color-violet-500)", - fuchsia: "var(--color-fuchsia-500)", - rose: "var(--color-rose-500)", - sky: "var(--color-sky-500)", - amber: "var(--color-amber-500)", -} as const; - -function getSizeClass(size: "default" | "sm" | "lg") { - switch (size) { - case "sm": - return "size-4"; - case "default": - return "size-5"; - case "lg": - return "size-6"; - default: - return "size-5"; - } -} - -function getColorValue(color: string): string { - return colorMap[color as keyof typeof colorMap] || color; -} - -export function ColorSelector({ - colors, - size = "default", - defaultValue, - name, - onColorSelect, - className, -}: ColorSelectorProps) { - const [selectedColor, setSelectedColor] = useState(defaultValue); - - const handleColorSelect = (color: string) => { - setSelectedColor(color); - onColorSelect?.(color); - }; - - const sizeClass = getSizeClass(size); - - return ( -
- {name && } - {colors.map((color) => { - const colorValue = getColorValue(color); - return ( -
handleColorSelect(color)} - onKeyDown={(e) => { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleColorSelect(color); - } - }} - tabIndex={0} - role="button" - aria-label={`Select ${color} color`} - aria-pressed={selectedColor === color} - /> - ); - })} -
- ); -} diff --git a/apps/web/src/modelSelection.ts b/apps/web/src/modelSelection.ts index 4ff195083d51..1a39f06098ac 100644 --- a/apps/web/src/modelSelection.ts +++ b/apps/web/src/modelSelection.ts @@ -153,7 +153,7 @@ function applyInstanceModelPreferences( ); } -export function normalizeCustomModelEntries( +function normalizeCustomModelEntries( models: ReadonlyArray, builtInModelSlugs: ReadonlySet, ): CustomModelDefinition[] { @@ -179,7 +179,7 @@ export function normalizeCustomModelEntries( return normalizedModels; } -export function getAppModelOptions( +function getAppModelOptions( settings: UnifiedSettings, providers: ReadonlyArray, provider: ProviderDriverKind, diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 428bbe91317b..fe95cf54890a 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -298,7 +298,7 @@ export function sortProviderInstanceEntries( * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. */ -export function getProviderInstanceEntry( +function getProviderInstanceEntry( providers: ReadonlyArray, instanceId: ProviderInstanceId, ): ProviderInstanceEntry | undefined { diff --git a/apps/web/src/providerModels.ts b/apps/web/src/providerModels.ts index b1bed7020e62..568e2b839b91 100644 --- a/apps/web/src/providerModels.ts +++ b/apps/web/src/providerModels.ts @@ -30,7 +30,7 @@ export function getProviderModels( return getProviderSnapshot(providers, provider)?.models ?? []; } -export function getProviderSnapshot( +function getProviderSnapshot( providers: ReadonlyArray, provider: ProviderDriverKind, ): ServerProvider | undefined { @@ -38,13 +38,6 @@ export function getProviderSnapshot( return providers.find((candidate) => candidate.instanceId === defaultInstanceId); } -export function getProviderInteractionModeToggle( - providers: ReadonlyArray, - provider: ProviderDriverKind, -): boolean { - return getProviderSnapshot(providers, provider)?.showInteractionModeToggle ?? true; -} - // Resolve an instance selection to the correlated live driver. If the // instance is absent, fall back to a live enabled provider instead of // inferring a driver from the missing instance id. diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 5c9d89e5ea33..258d643efd5a 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -16,7 +16,6 @@ import { type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, - ProviderDriverKind, ProviderApprovalOption, ProviderRequestKind, type ToolLifecycleItemType, @@ -38,43 +37,6 @@ import { export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; -export type ProviderPickerKind = ProviderDriverKind; - -export const PROVIDER_OPTIONS: Array<{ - value: ProviderPickerKind; - label: string; - available: boolean; - /** Shown on the model picker sidebar when relevant */ - pickerSidebarBadge?: "new" | "soon"; -}> = [ - { value: ProviderDriverKind.make("codex"), label: "Codex", available: true }, - { value: ProviderDriverKind.make("claudeAgent"), label: "Claude", available: true }, - { - value: ProviderDriverKind.make("opencode"), - label: "OpenCode", - available: true, - pickerSidebarBadge: "new", - }, - { - value: ProviderDriverKind.make("cursor"), - label: "Cursor", - available: true, - pickerSidebarBadge: "new", - }, - { - value: ProviderDriverKind.make("grok"), - label: "Grok", - available: true, - pickerSidebarBadge: "new", - }, - { - value: ProviderDriverKind.make("antigravity"), - label: "Antigravity", - available: true, - pickerSidebarBadge: "new", - }, -]; - export type WorkLogToolLifecycleStatus = | "inProgress" | "completed" From 2759ef05bb1cccc55fb2ac747bf8769de8d745e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:52:52 -0700 Subject: [PATCH 016/320] chore(mobile): remove obsolete widget wiring script (#9960) --- .../scripts/wire-widget-asset-catalog.cjs | 30 ------------------- 1 file changed, 30 deletions(-) delete mode 100644 apps/mobile/scripts/wire-widget-asset-catalog.cjs diff --git a/apps/mobile/scripts/wire-widget-asset-catalog.cjs b/apps/mobile/scripts/wire-widget-asset-catalog.cjs deleted file mode 100644 index b7c70f0cdcd6..000000000000 --- a/apps/mobile/scripts/wire-widget-asset-catalog.cjs +++ /dev/null @@ -1,30 +0,0 @@ -"use strict"; - -// One-off: apply the widget asset-catalog wiring to the already-generated -// ios/ project so the current build compiles ExpoWidgetsTarget/Assets.xcassets -// without a full `expo prebuild`. The durable equivalent lives in -// plugins/withWidgetLogoAsset.cjs and runs on prebuild. - -const path = require("path"); -const fs = require("fs"); - -const xcodePath = require.resolve("xcode", { - paths: [ - require.resolve("@expo/config-plugins", { paths: [require.resolve("expo/package.json")] }), - ], -}); -const xcode = require(xcodePath); -const { addWidgetAssetCatalog } = require("../plugins/lib/addWidgetAssetCatalog.cjs"); - -const pbxprojPath = path.join(__dirname, "..", "ios", "T3CodeDev.xcodeproj", "project.pbxproj"); -const proj = xcode.project(pbxprojPath); -proj.parseSync(); - -const added = addWidgetAssetCatalog(proj, { targetName: "ExpoWidgetsTarget" }); - -if (added) { - fs.writeFileSync(pbxprojPath, proj.writeSync()); - console.log("Added widget asset-compile phase to ExpoWidgetsTarget."); -} else { - console.log("No change: phase already present."); -} From 00d46188bedfae81a5d219c8eba4b63e8cf90036 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:52:52 -0700 Subject: [PATCH 017/320] chore: remove redundant root tooling dependencies (#9961) --- package.json | 2 -- pnpm-lock.yaml | 8 +------- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/package.json b/package.json index 3efa732f9c28..4ea8fa7036b4 100644 --- a/package.json +++ b/package.json @@ -46,9 +46,7 @@ "sync:repos": "node scripts/sync-reference-repos.ts" }, "devDependencies": { - "@babel/plugin-transform-react-jsx": "7.28.6", "@effect/tsgo": "catalog:", - "@oxlint/plugins": "^1.63.0", "@types/node": "catalog:", "@typescript/native-preview": "catalog:", "knip": "6.34.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a22a638cdee2..595627a489ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -109,15 +109,9 @@ importers: .: devDependencies: - '@babel/plugin-transform-react-jsx': - specifier: 7.28.6 - version: 7.28.6(@babel/core@7.29.7) '@effect/tsgo': specifier: 'catalog:' version: 0.13.2 - '@oxlint/plugins': - specifier: ^1.63.0 - version: 1.68.0 '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -14304,7 +14298,7 @@ snapshots: '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-display-name': 7.29.7(@babel/core@7.29.7) - '@babel/plugin-transform-react-jsx': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-react-jsx': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-self': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-react-jsx-source': 7.29.7(@babel/core@7.29.7) '@babel/plugin-transform-regenerator': 7.29.7(@babel/core@7.29.7) From d2c3e2e5d7360b5d87f7516bdbce46b5c5444ef4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 4 Sep 2026 23:52:53 -0700 Subject: [PATCH 018/320] ci: reject unused files and dependencies with Knip (#9962) --- .github/workflows/ci.yml | 4 ++++ docs/operations/development.md | 9 +++++++++ package.json | 1 + 3 files changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c0866fa63a6..c6d07c653040 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,10 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Export cleanup is still a manual audit; files and dependencies have no baseline. + - name: Check unused files and dependencies + run: vp run knip:check + - name: Check run: vp check diff --git a/docs/operations/development.md b/docs/operations/development.md index dec3e8681fd3..d415badb61d0 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -69,6 +69,15 @@ Use `vp run lint:mobile` for native mobile changes. CI owns the full suite; see The [manual Windows lane](../../.github/workflows/windows-tests.yml) is available for focused Windows investigation while that suite is not a required gate. +### Unused code + +`vp run knip:check` runs the unused-file and dependency check enforced by CI. +Use `vp run knip --workspace apps/web` to audit one workspace, including exports, +or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. +The full export audit still has findings and is not a CI gate. Review callers before +deleting code; production mode can also report development scripts and test fixtures. +Runtime-discovered entrypoints and dependency exceptions belong in [knip.jsonc](../../knip.jsonc). + ## Desktop artifacts Local artifact builds are unsigned by default and write to `release/`: diff --git a/package.json b/package.json index 4ea8fa7036b4..20f3f30837b2 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip", + "knip:check": "knip --include files,dependencies --no-config-hints", "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From dfc70a3294641a2acb021766a759ac4c79665944 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:02:19 -0700 Subject: [PATCH 019/320] test(contracts): keep driver default lookup private (#9968) --- packages/contracts/src/settings.test.ts | 13 ++++--------- packages/contracts/src/settings.ts | 2 +- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index c5d9c52a7175..1673f4ad159b 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -7,7 +7,6 @@ import { ClientSettingsPatch, ClaudeSettings, DEFAULT_SERVER_SETTINGS, - defaultEnabledForDriver, resolveProviderInstanceEnabled, ServerSettings, ServerSettingsPatch, @@ -432,14 +431,6 @@ describe("provider enabled defaults", () => { expect(decoded.providers.opencode.enabled).toBe(false); }); - it("derives per-driver defaults from the settings schemas", () => { - expect(defaultEnabledForDriver(ProviderDriverKind.make("codex"))).toBe(true); - expect(defaultEnabledForDriver(ProviderDriverKind.make("cursor"))).toBe(false); - expect(defaultEnabledForDriver(ProviderDriverKind.make("grok"))).toBe(false); - // Unknown fork drivers stay enabled; their own build decides otherwise. - expect(defaultEnabledForDriver(ProviderDriverKind.make("ollama"))).toBe(true); - }); - it("keeps Cursor enabled when an existing user explicitly opted in", () => { const cursor = ProviderDriverKind.make("cursor"); const cursorId = ProviderInstanceId.make("cursor"); @@ -460,6 +451,10 @@ describe("provider enabled defaults", () => { // No flags anywhere: driver default applies. expect(resolveProviderInstanceEnabled({ driver: grok, config: {} })).toBe(false); expect(resolveProviderInstanceEnabled({ driver: codex, config: {} })).toBe(true); + // Unknown fork drivers stay enabled. + expect( + resolveProviderInstanceEnabled({ driver: ProviderDriverKind.make("ollama"), config: {} }), + ).toBe(true); // Envelope flag wins over the driver default. expect(resolveProviderInstanceEnabled({ driver: grok, enabled: true, config: {} })).toBe(true); expect(resolveProviderInstanceEnabled({ driver: codex, enabled: false, config: {} })).toBe( diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index cf68dcf62de8..50923423352b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -975,7 +975,7 @@ export const providerInstanceConfigEnabledFlag = (config: unknown): boolean | un * through `DEFAULT_SERVER_SETTINGS`, so the schema's decoding default stays * the single source of truth. Unknown (fork) drivers default to enabled. */ -export const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { +const defaultEnabledForDriver = (driver: ProviderDriverKind): boolean => { const legacyDefaults = DEFAULT_SERVER_SETTINGS.providers as Record< string, { readonly enabled?: boolean } | undefined From 32142cff186c607c8cae644927cee8d6c9cba757 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:05:07 -0700 Subject: [PATCH 020/320] test(server): remove Azure permissions constant snapshot (#9973) --- .../AzureDevOpsPullRequestProvider.test.ts | 29 ------------------- .../AzureDevOpsPullRequestProvider.ts | 2 +- 2 files changed, 1 insertion(+), 30 deletions(-) delete mode 100644 apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts deleted file mode 100644 index 51d8f74bbc45..000000000000 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; - -import { AZURE_DEVOPS_VIEWER_PERMISSIONS } from "./AzureDevOpsPullRequestProvider.ts"; - -describe("azure devops viewer permissions", () => { - it("offers every action to whoever is signed in, because Azure names no permission", () => { - // The same answer for a viewer who can write, one who can only read, and an author with read - // access: `az repos pr show` and `az repos pr list` carry nothing about the caller's standing, - // and an unknown permission is granted rather than guessed away. Azure refuses the ones it - // will not allow, at the moment they are taken, in words this could not have written. - expect(AZURE_DEVOPS_VIEWER_PERMISSIONS).toEqual({ - actions: [ - "merge", - "ready", - "draft", - "close", - "reopen", - "enable-auto-merge", - "disable-auto-merge", - ], - // False because the host itself cannot post one, not because this viewer may not. - comment: false, - resolve: false, - verdicts: [], - // True because `az repos pr reviewer` does take one, and Azure says nothing about who may. - requestReviewers: true, - }); - }); -}); diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 8461e57d5685..3d501a32d61b 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -56,7 +56,7 @@ const CAPABILITIES: PullRequestCapabilities = { * they try. That is the safer half of an unknown: hiding a control from someone entitled to it * leaves them no way through and no reason given. */ -export const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { +const AZURE_DEVOPS_VIEWER_PERMISSIONS: PullRequestViewerPermissions = { actions: CAPABILITIES.actions, comment: CAPABILITIES.comment, resolve: CAPABILITIES.review.resolve, From 1568b3fd083e198a08bb12353820c1ef99fb3420 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:23 -0700 Subject: [PATCH 021/320] refactor(shared): remove unused viewport formatters (#9970) --- packages/shared/src/previewViewport.test.ts | 15 +-------------- packages/shared/src/previewViewport.ts | 11 ----------- 2 files changed, 1 insertion(+), 25 deletions(-) diff --git a/packages/shared/src/previewViewport.test.ts b/packages/shared/src/previewViewport.test.ts index 3222e90d7be5..7a049376c50e 100644 --- a/packages/shared/src/previewViewport.test.ts +++ b/packages/shared/src/previewViewport.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - PREVIEW_VIEWPORT_PRESETS, - previewViewportLabel, - previewViewportPresetOrientation, - resolvePreviewViewport, -} from "./previewViewport.ts"; +import { PREVIEW_VIEWPORT_PRESETS, resolvePreviewViewport } from "./previewViewport.ts"; describe("previewViewport", () => { it("resolves fill and exact freeform viewports", () => { @@ -59,12 +54,4 @@ describe("previewViewport", () => { "Nest Hub Max", ]); }); - - it("formats settings for compact UI", () => { - expect(previewViewportLabel({ _tag: "fill" })).toBe("Fill panel"); - expect(previewViewportLabel({ _tag: "freeform", width: 393, height: 852 })).toBe("393 × 852"); - expect(previewViewportPresetOrientation({ _tag: "freeform", width: 852, height: 393 })).toBe( - "landscape", - ); - }); }); diff --git a/packages/shared/src/previewViewport.ts b/packages/shared/src/previewViewport.ts index 1d70bca5dfbd..d1e066bee16d 100644 --- a/packages/shared/src/previewViewport.ts +++ b/packages/shared/src/previewViewport.ts @@ -173,14 +173,3 @@ export function resolvePreviewViewport( height: input.height, }; } - -export function previewViewportLabel(viewport: PreviewViewportSetting): string { - return viewport._tag === "fill" ? "Fill panel" : `${viewport.width} × ${viewport.height}`; -} - -export function previewViewportPresetOrientation( - viewport: PreviewViewportSetting, -): "portrait" | "landscape" | null { - if (viewport._tag === "fill" || viewport.width === viewport.height) return null; - return viewport.width > viewport.height ? "landscape" : "portrait"; -} From c1e279eca52e02ee3fb9c8d133057a30526f282c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:25 -0700 Subject: [PATCH 022/320] refactor(mobile): remove unused provider option summary (#9971) --- apps/mobile/src/lib/providerOptions.test.ts | 18 ++---------------- apps/mobile/src/lib/providerOptions.ts | 18 ------------------ 2 files changed, 2 insertions(+), 34 deletions(-) diff --git a/apps/mobile/src/lib/providerOptions.test.ts b/apps/mobile/src/lib/providerOptions.test.ts index d87df6baaf1d..9b94cecb3db9 100644 --- a/apps/mobile/src/lib/providerOptions.test.ts +++ b/apps/mobile/src/lib/providerOptions.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import type { ModelCapabilities } from "@t3tools/contracts"; -import { - applyProviderOptionSelection, - providerOptionValueLabels, - resolveProviderOptionDescriptors, -} from "./providerOptions"; +import { applyProviderOptionSelection, resolveProviderOptionDescriptors } from "./providerOptions"; const CODEX_CAPABILITIES: ModelCapabilities = { optionDescriptors: [ @@ -34,15 +30,6 @@ const CODEX_CAPABILITIES: ModelCapabilities = { }; describe("mobile provider options", () => { - it("summarizes the option values currently in effect", () => { - const descriptors = resolveProviderOptionDescriptors({ - capabilities: CODEX_CAPABILITIES, - selections: undefined, - }); - - expect(providerOptionValueLabels(descriptors)).toEqual(["Medium", "Standard"]); - }); - it("updates generic select options without knowing provider-specific ids", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: CODEX_CAPABILITIES, @@ -62,7 +49,7 @@ describe("mobile provider options", () => { expect(applyProviderOptionSelection(descriptors, { id: "unknown", value: "high" })).toBeNull(); }); - it("treats an unspecified boolean capability as off", () => { + it("updates generic boolean options", () => { const descriptors = resolveProviderOptionDescriptors({ capabilities: { optionDescriptors: [{ id: "fastMode", label: "Fast Mode", type: "boolean" }], @@ -70,7 +57,6 @@ describe("mobile provider options", () => { selections: undefined, }); - expect(providerOptionValueLabels(descriptors)).toEqual([]); expect(applyProviderOptionSelection(descriptors, { id: "fastMode", value: true })).toEqual([ { id: "fastMode", value: true }, ]); diff --git a/apps/mobile/src/lib/providerOptions.ts b/apps/mobile/src/lib/providerOptions.ts index 593f5a37442c..dec0d327030d 100644 --- a/apps/mobile/src/lib/providerOptions.ts +++ b/apps/mobile/src/lib/providerOptions.ts @@ -5,7 +5,6 @@ import type { } from "@t3tools/contracts"; import { buildProviderOptionSelectionsFromDescriptors, - getProviderOptionCurrentLabel, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -22,23 +21,6 @@ export function resolveProviderOptionDescriptors(input: { }); } -/** - * Labels for the option values currently in effect (select values plus - * enabled booleans), used to summarize the thread configuration in the - * composer trigger pill. - */ -export function providerOptionValueLabels( - descriptors: ReadonlyArray, -): ReadonlyArray { - return descriptors.flatMap((descriptor) => { - if (descriptor.type === "boolean") { - return descriptor.currentValue ? [descriptor.label] : []; - } - const label = getProviderOptionCurrentLabel(descriptor); - return label ? [label] : []; - }); -} - /** * Applies one option change (by descriptor id) and returns the full selection * list to store on the model selection, or null when the change doesn't match From 1782a2af44a7630f184a05dbcf788e6121da2554 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:26 -0700 Subject: [PATCH 023/320] refactor(client-runtime): remove unused connection phase message (#9972) --- .../src/connection/presentation.test.ts | 5 ---- .../src/connection/presentation.ts | 24 +------------------ 2 files changed, 1 insertion(+), 28 deletions(-) diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index e13638a2b41f..80ce8a374a9c 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -10,7 +10,6 @@ import { } from "./model.ts"; import { connectionCatalogDisplayUrl, - connectionPhaseMessage, connectionStatusText, connectionStatusTitle, presentEnvironmentConnection, @@ -119,10 +118,6 @@ describe("connection presentation", () => { }); }); - it("gives offline status precedence in global messaging", () => { - expect(connectionPhaseMessage("connected", TARGET.label, "offline")).toBe("You are offline"); - }); - it("combines reconnect progress with the latest failure", () => { const connection = { phase: "reconnecting", diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index 168443deceb4..4093167d333c 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -2,7 +2,7 @@ import type { ServerConfig } from "@t3tools/contracts"; import * as Option from "effect/Option"; import type { ConnectionCatalogEntry } from "./catalog.ts"; -import type { NetworkStatus, SupervisorConnectionState } from "./model.ts"; +import type { SupervisorConnectionState } from "./model.ts"; export type EnvironmentConnectionPhase = | "available" @@ -105,25 +105,3 @@ export function connectionCatalogDisplayUrl(entry: ConnectionCatalogEntry): stri : null; } } - -export function connectionPhaseMessage( - phase: EnvironmentConnectionPhase, - label: string, - networkStatus: NetworkStatus, -): string { - if (networkStatus === "offline" || phase === "offline") { - return "You are offline"; - } - switch (phase) { - case "available": - return "Available"; - case "connecting": - return `Connecting to ${label}...`; - case "reconnecting": - return `Reconnecting to ${label}...`; - case "connected": - return "Connected"; - case "error": - return "Connection failed"; - } -} From eda0cec92398a4097958b55accaa59fdd178a977 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:42 -0700 Subject: [PATCH 024/320] refactor(mobile): remove unused layout calculations (#9974) --- apps/mobile/src/lib/layout.test.ts | 19 --------------- apps/mobile/src/lib/layout.ts | 38 ------------------------------ 2 files changed, 57 deletions(-) diff --git a/apps/mobile/src/lib/layout.test.ts b/apps/mobile/src/lib/layout.test.ts index 8342fd1aeebc..7d288b1352a5 100644 --- a/apps/mobile/src/lib/layout.test.ts +++ b/apps/mobile/src/lib/layout.test.ts @@ -2,11 +2,9 @@ import { describe, expect, it } from "vite-plus/test"; import { constrainAuxiliaryPaneWidth, - constrainPrimarySidebarWidth, deriveCenteredContentHorizontalPadding, deriveFileInspectorPaneLayout, deriveLayout, - deriveStableFormSheetDetent, deriveThreadFeedInitialContentInset, deriveThreadWorkLogSizing, deriveWorkspacePaneLayout, @@ -75,12 +73,6 @@ describe("deriveThreadFeedInitialContentInset", () => { }); describe("resizable pane constraints", () => { - it("keeps a preferred sidebar width across large windows and clamps it in a narrow split view", () => { - expect(constrainPrimarySidebarWidth(430, 1_366)).toBe(430); - expect(constrainPrimarySidebarWidth(430, 744)).toBe(384); - expect(constrainPrimarySidebarWidth(100, 1_366)).toBe(280); - }); - it("preserves a useful main pane while constraining a trailing pane", () => { expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 1_100 })).toBe(440); expect(constrainAuxiliaryPaneWidth({ preferredWidth: 440, availableWidth: 900 })).toBe(340); @@ -392,14 +384,3 @@ describe("deriveWorkspacePaneLayout", () => { }); }); }); - -describe("deriveStableFormSheetDetent", () => { - it.each([ - { height: 1_194, expected: 0.62 }, - { height: 834, expected: 0.863 }, - { height: 600, expected: 0.893 }, - { height: 0, expected: 0.92 }, - ])("derives a stable sheet detent for height $height", ({ height, expected }) => { - expect(deriveStableFormSheetDetent(height)).toBe(expected); - }); -}); diff --git a/apps/mobile/src/lib/layout.ts b/apps/mobile/src/lib/layout.ts index 4199dc8dc8ed..ee38ac020e74 100644 --- a/apps/mobile/src/lib/layout.ts +++ b/apps/mobile/src/lib/layout.ts @@ -16,7 +16,6 @@ export const SPLIT_LAYOUT_MIN_WIDTH = 720; export const SPLIT_LAYOUT_MIN_HEIGHT = 600; export const SPLIT_SIDEBAR_MIN_WIDTH = 280; -export const SPLIT_SIDEBAR_MAX_WIDTH = 460; const SPLIT_SIDEBAR_DEFAULT_MAX_WIDTH = 380; export const AUXILIARY_PANE_MIN_CONTENT_WIDTH = 960; @@ -50,10 +49,6 @@ export const AUXILIARY_PANE_MAX_WIDTH = 480; const AUXILIARY_PANE_DEFAULT_MAX_WIDTH = 320; const FILE_INSPECTOR_MIN_VIEWPORT_WIDTH = 820; const FILE_INSPECTOR_MIN_MAIN_WIDTH = 560; -const STABLE_FORM_SHEET_MAX_HEIGHT = 720; -const STABLE_FORM_SHEET_VERTICAL_MARGIN = 64; -const STABLE_FORM_SHEET_MIN_DETENT = 0.62; -const STABLE_FORM_SHEET_MAX_DETENT = 0.92; export type LayoutVariant = "compact" | "split"; @@ -218,22 +213,6 @@ export function deriveFileInspectorPaneLayout(input: { }; } -/** Keep a user-selected sidebar width useful as a window is resized. */ -export function constrainPrimarySidebarWidth( - preferredWidth: number, - viewportWidth = Number.POSITIVE_INFINITY, -): number { - const safeWidth = Number.isFinite(preferredWidth) ? preferredWidth : SPLIT_SIDEBAR_MIN_WIDTH; - const viewportMax = Number.isFinite(viewportWidth) - ? Math.max(SPLIT_SIDEBAR_MIN_WIDTH, viewportWidth - 360) - : SPLIT_SIDEBAR_MAX_WIDTH; - return clamp( - Math.round(safeWidth), - SPLIT_SIDEBAR_MIN_WIDTH, - Math.min(SPLIT_SIDEBAR_MAX_WIDTH, viewportMax), - ); -} - /** * Keep an auxiliary pane within native-feeling bounds without squeezing its * neighboring content below a usable reading/editor width. @@ -275,20 +254,3 @@ export function deriveCenteredContentHorizontalPadding(input: { return minimumPadding + Math.max(0, (viewportWidth - input.maxContentWidth) / 2); } - -export function deriveStableFormSheetDetent(containerHeight: number): number { - if (!Number.isFinite(containerHeight) || containerHeight <= 0) { - return STABLE_FORM_SHEET_MAX_DETENT; - } - - const targetHeight = Math.min( - STABLE_FORM_SHEET_MAX_HEIGHT, - Math.max(0, containerHeight - STABLE_FORM_SHEET_VERTICAL_MARGIN), - ); - const detent = clamp( - targetHeight / containerHeight, - STABLE_FORM_SHEET_MIN_DETENT, - STABLE_FORM_SHEET_MAX_DETENT, - ); - return Math.round(detent * 1_000) / 1_000; -} From 044a6e168ad1eb213c2c7c277034e65c638cc937 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:09:44 -0700 Subject: [PATCH 025/320] refactor(client-runtime): remove unused file position predicate (#9976) --- packages/client-runtime/src/markdownLinks.test.ts | 10 ---------- packages/client-runtime/src/markdownLinks.ts | 9 --------- 2 files changed, 19 deletions(-) diff --git a/packages/client-runtime/src/markdownLinks.test.ts b/packages/client-runtime/src/markdownLinks.test.ts index 42cd7a35e473..4aea947b87d6 100644 --- a/packages/client-runtime/src/markdownLinks.test.ts +++ b/packages/client-runtime/src/markdownLinks.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { fileBasename, inlineCodeFilePathCandidate, - isConventionalFilePosition, parseFileUrlHref, parseMarkdownFileLink, splitFilePathPosition, @@ -28,15 +27,6 @@ describe("inlineCodeFilePathCandidate", () => { }); }); -describe("isConventionalFilePosition", () => { - it("distinguishes extensionless file locations from labels and ports", () => { - expect(isConventionalFilePosition("Dockerfile:8:2")).toBe(true); - expect(isConventionalFilePosition("Makefile")).toBe(false); - expect(isConventionalFilePosition("TODO:12")).toBe(false); - expect(isConventionalFilePosition("port:3000")).toBe(false); - }); -}); - describe("parseFileUrlHref", () => { it.each([ ["file:///Users/julius/project/src/main.ts#L42", "/Users/julius/project/src/main.ts", "#L42"], diff --git a/packages/client-runtime/src/markdownLinks.ts b/packages/client-runtime/src/markdownLinks.ts index 2e455655d005..29a337e49c42 100644 --- a/packages/client-runtime/src/markdownLinks.ts +++ b/packages/client-runtime/src/markdownLinks.ts @@ -15,7 +15,6 @@ const INLINE_CODE_DISQUALIFIER_PATTERN = /[\s`]/; const PATH_SEPARATOR_PATTERN = /[\\/]/; const FILE_EXTENSION_PATTERN = /\.[A-Za-z0-9_-]+$/; const NUMERIC_DOTTED_PATTERN = /^\d+(?:\.\d+)+$/; -const BARE_EXTENSIONLESS_POSITION_PATTERN = /^[A-Za-z0-9_-]+(?::\d+){1,2}$/; // Standard OS and dev-container roots; deliberately excludes app-route-ish // prefixes like /app/ or /chat/ so SPA routes never read as files. const POSIX_FILE_ROOT_PREFIXES = [ @@ -152,14 +151,6 @@ function looksLikeHostname(segment: string, hasPosition: boolean): boolean { return !hasPosition && COUNTRY_HOSTNAME_TLDS.has(lastLabel); } -/** Recognizes conventional extensionless filenames with an explicit line position. */ -export function isConventionalFilePosition(path: string): boolean { - return ( - BARE_EXTENSIONLESS_POSITION_PATTERN.test(path) && - EXTENSIONLESS_FILE_NAMES.has(path.replace(POSITION_SUFFIX_PATTERN, "")) - ); -} - /** * Picks path-shaped inline code for the client's markdown file-link resolver. * It does not resolve paths or turn plain prose and fenced code into links. From 5fe29c89456c151529a63ddcbf132b4c518f1838 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:21 -0700 Subject: [PATCH 026/320] refactor(mobile): remove unused font size steppers (#9975) --- apps/mobile/src/lib/appearancePreferences.test.ts | 7 +------ apps/mobile/src/lib/appearancePreferences.ts | 10 ---------- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index 3458f0120f99..af09637b4e9f 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -13,8 +13,6 @@ import { resolveMobileCodeSurface, resolveNativeMarkdownTypography, resolveTextScaleVariables, - stepBaseFontSize, - stepCodeFontSize, stepTerminalFontSize, } from "./appearancePreferences"; @@ -73,11 +71,8 @@ describe("appearancePreferences", () => { expect(normalizeCodeFontSize(30)).toBe(18); }); - it("steps font sizes within bounds", () => { + it("steps terminal font size within bounds", () => { expect(stepTerminalFontSize(6, -1)).toBe(6); - expect(stepBaseFontSize(11, -1)).toBe(11); - expect(stepCodeFontSize(8, -1)).toBe(8); - expect(stepBaseFontSize(15, 1)).toBe(16); }); it("scales markdown typography from the base size", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index d2504a629dda..b81c50056543 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -235,22 +235,12 @@ export function resolveNativeMarkdownTypography(baseFontSize: number): NativeMar }; } -export function stepBaseFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - BASE_FONT_SIZE_STEP : current + BASE_FONT_SIZE_STEP; - return normalizeBaseFontSize(next); -} - export function stepTerminalFontSize(current: number, direction: -1 | 1): number { const next = direction === -1 ? current - TERMINAL_FONT_SIZE_STEP : current + TERMINAL_FONT_SIZE_STEP; return normalizeTerminalFontSize(next); } -export function stepCodeFontSize(current: number, direction: -1 | 1): number { - const next = direction === -1 ? current - CODE_FONT_SIZE_STEP : current + CODE_FONT_SIZE_STEP; - return normalizeCodeFontSize(next); -} - export { DEFAULT_TERMINAL_FONT_SIZE, MAX_TERMINAL_FONT_SIZE, From b3f8dd979af2bb089f94e6f1006ebbb2cf748db4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:26 -0700 Subject: [PATCH 027/320] test(server): cover thread lookup through command invariants (#9978) --- .../src/orchestration/commandInvariants.test.ts | 11 ++--------- apps/server/src/orchestration/commandInvariants.ts | 2 +- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 9aaeba943423..93777c67d3e1 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -11,12 +11,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import { - findThreadById, - listThreadsByProjectId, - requireThread, - requireThreadAbsent, -} from "./commandInvariants.ts"; +import { listThreadsByProjectId, requireThread, requireThreadAbsent } from "./commandInvariants.ts"; const now = "2026-01-01T00:00:00.000Z"; @@ -121,9 +116,7 @@ const messageSendCommand: OrchestrationCommand = { }; describe("commandInvariants", () => { - it("finds threads by id and project", () => { - expect(findThreadById(readModel, ThreadId.make("thread-1"))?.projectId).toBe("project-a"); - expect(findThreadById(readModel, ThreadId.make("missing"))).toBeUndefined(); + it("lists threads by project", () => { expect( listThreadsByProjectId(readModel, ProjectId.make("project-b")).map((thread) => thread.id), ).toEqual([ThreadId.make("thread-2")]); diff --git a/apps/server/src/orchestration/commandInvariants.ts b/apps/server/src/orchestration/commandInvariants.ts index beaad93d5eef..110a499d37c9 100644 --- a/apps/server/src/orchestration/commandInvariants.ts +++ b/apps/server/src/orchestration/commandInvariants.ts @@ -18,7 +18,7 @@ function invariantError(commandType: string, detail: string): OrchestrationComma }); } -export function findThreadById( +function findThreadById( readModel: OrchestrationReadModel, threadId: ThreadId, ): OrchestrationThread | undefined { From 2fc630c9e85321b7b3963372fd316877a7581639 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:30 -0700 Subject: [PATCH 028/320] test(server): remove provider equality wrapper fixture (#9979) --- .../provider/Layers/ProviderRegistry.test.ts | 34 ------------------- .../src/provider/Layers/ProviderRegistry.ts | 2 +- 2 files changed, 1 insertion(+), 35 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index cf3fe15ea9a5..ffabf6d8d4f3 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -43,7 +43,6 @@ import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; import { - haveProvidersChanged, mergeProviderSnapshot, upsertProviderWorkspaceSnapshot, ProviderRegistryLive, @@ -555,39 +554,6 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }); describe("ProviderRegistryLive", () => { - it("treats equal provider snapshots as unchanged", () => { - const providers = [ - { - instanceId: ProviderInstanceId.make("codex"), - driver: ProviderDriverKind.make("codex"), - status: "ready", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - { - instanceId: ProviderInstanceId.make("claudeAgent"), - driver: ProviderDriverKind.make("claudeAgent"), - status: "warning", - enabled: true, - installed: true, - auth: { status: "unknown" }, - checkedAt: "2026-03-25T00:00:00.000Z", - version: "1.0.0", - models: [], - slashCommands: [], - skills: [], - }, - ] as const satisfies ReadonlyArray; - - assert.strictEqual(haveProvidersChanged(providers, [...providers]), false); - }); - it("stores workspace skills and commands without changing machine metadata", () => { const provider = { instanceId: ProviderInstanceId.make("codex"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 2fd4278f3c57..94dde9f931bb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -188,7 +188,7 @@ export const mergeProviderSnapshot = ( : {}), }; -export const haveProvidersChanged = ( +const haveProvidersChanged = ( previousProviders: ReadonlyArray, nextProviders: ReadonlyArray, ): boolean => !Equal.equals(previousProviders, nextProviders); From 1550f1b742138df8b31b24e34ddea58c80afb3cc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:34 -0700 Subject: [PATCH 029/320] test(server): assert the dispatched welcome thread model (#9980) --- apps/server/src/serverRuntimeStartup.test.ts | 20 ++++++++------------ apps/server/src/serverRuntimeStartup.ts | 2 +- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index b53e1843c226..0b909d96f43d 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -17,13 +17,6 @@ import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; -it("uses the canonical Codex default for the auto-bootstrapped welcome thread", () => { - assert.deepStrictEqual(ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); -}); - it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => Effect.gen(function* () { const pulled: string[] = []; @@ -201,7 +194,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa id: bootstrapProjectId, title: "Startup Project", workspaceRoot: "/tmp/startup-project", - defaultModelSelection: ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, scripts: [], createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", @@ -298,10 +294,10 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when ["project.create", "thread.create"], ); assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual( - commands[1]?.modelSelection, - ServerRuntimeStartup.getAutoBootstrapThreadModelSelection(), - ); + assert.deepStrictEqual(commands[1]?.modelSelection, { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }); }), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index a34d1bdbde91..7a5e7b12b865 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -176,7 +176,7 @@ export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( Effect.asVoid, ); -export const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ +const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, }); From 14da634577773d291c494635c1a4bcdb0e70f9ee Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:39 -0700 Subject: [PATCH 030/320] refactor(desktop): remove unused keyring remediation text (#9981) --- apps/desktop/src/linuxSecretStorage.test.ts | 77 ---------------- apps/desktop/src/linuxSecretStorage.ts | 99 --------------------- 2 files changed, 176 deletions(-) diff --git a/apps/desktop/src/linuxSecretStorage.test.ts b/apps/desktop/src/linuxSecretStorage.test.ts index a91790200771..5827e38e406f 100644 --- a/apps/desktop/src/linuxSecretStorage.test.ts +++ b/apps/desktop/src/linuxSecretStorage.test.ts @@ -3,7 +3,6 @@ import { describe, expect, it } from "vite-plus/test"; import { normalizeLinuxPasswordStorePreference, resolveLinuxPasswordStoreSwitch, - resolveLinuxSecretStorageUnavailableMessage, } from "./linuxSecretStorage.ts"; const autoSwitch = (env: NodeJS.ProcessEnv) => @@ -124,80 +123,4 @@ describe("linuxSecretStorage", () => { }), ).toBe("gnome-libsecret"); }); - - it("uses GNOME Keyring remediation for libsecret and unknown backends", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit libsecret selection over KDE desktop heuristics", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "gnome-libsecret", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("GNOME Keyring"); - }); - - it("prefers explicit KWallet preference over selected gnome-libsecret backend", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet6", - selectedBackend: "gnome_libsecret", - env: { XDG_CURRENT_DESKTOP: "niri" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "kwallet", - selectedBackend: "gnome-libsecret", - env: {}, - }), - ).toContain("KWallet"); - }); - - it("uses KWallet remediation wording for KDE-looking sessions", () => { - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "kwallet6", - env: {}, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { XDG_CURRENT_DESKTOP: "KDE" }, - }), - ).toContain("KWallet"); - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { DESKTOP_SESSION: "plasmawayland" }, - }), - ).toContain("KWallet"); - // A desktop name outranks a bare KDE marker when choosing the wording. - expect( - resolveLinuxSecretStorageUnavailableMessage({ - configuredPreference: "auto", - selectedBackend: "unknown", - env: { GDMSESSION: "gnome", KDE_FULL_SESSION: "true" }, - }), - ).toContain("GNOME Keyring"); - }); }); diff --git a/apps/desktop/src/linuxSecretStorage.ts b/apps/desktop/src/linuxSecretStorage.ts index fe3e21eadb92..3aa7a440d1e8 100644 --- a/apps/desktop/src/linuxSecretStorage.ts +++ b/apps/desktop/src/linuxSecretStorage.ts @@ -25,9 +25,6 @@ const ELECTRON_KDE_DESKTOP = "KDE"; // Chromium recognizes LXQt and still selects basic text for it, so it does need a forced backend. const ELECTRON_UNPROTECTED_DESKTOPS = new Set(["LXQt"]); -const KDE_NAME_PREFIXES = ["kde", "plasma"]; -const NEGATIVE_FLAG_VALUES = new Set(["0", "false", "no", "off"]); - export function normalizeLinuxPasswordStorePreference( value: unknown, ): LinuxPasswordStorePreference { @@ -77,102 +74,6 @@ function electronSelectsProtectedBackend(env: NodeJS.ProcessEnv): boolean { return false; } -export function resolveLinuxSecretStorageUnavailableMessage(input: { - readonly configuredPreference: LinuxPasswordStorePreference; - readonly selectedBackend: string | null; - readonly env: NodeJS.ProcessEnv; -}): string { - if (input.configuredPreference === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - input.configuredPreference === "kwallet" || - input.configuredPreference === "kwallet5" || - input.configuredPreference === "kwallet6" - ) { - return getKWalletRemediationMessage(); - } - - const backend = normalizeSelectedStorageBackend(input.selectedBackend); - if (backend === "gnome-libsecret") { - return getGnomeKeyringRemediationMessage(); - } - - if ( - backend === "kwallet" || - backend === "kwallet5" || - backend === "kwallet6" || - looksLikeKdeSession(input.env) - ) { - return getKWalletRemediationMessage(); - } - - return getGnomeKeyringRemediationMessage(); -} - -function getGnomeKeyringRemediationMessage(): string { - return "T3 Code could not access GNOME Keyring to save this environment credential. Install and start GNOME Keyring, then restart T3 Code."; -} - -function getKWalletRemediationMessage(): string { - return "T3 Code could not access KWallet to save this environment credential. Enable the KDE wallet subsystem in System Settings, then restart T3 Code."; -} - -// Advisory only: this picks between the GNOME Keyring and KWallet wording in the failure notice. It -// never decides which backend to select, so a loose match costs a user slightly wrong instructions -// rather than an unprotected credential store. -function looksLikeKdeSession(env: NodeJS.ProcessEnv): boolean { - const currentDesktopNames = nonEmptyDesktopNames(env.XDG_CURRENT_DESKTOP); - if (currentDesktopNames.length > 0) { - return currentDesktopNames.some(isKdeDesktopName); - } - - const legacyNames = legacyDesktopNames(env); - if (legacyNames.length > 0) { - return legacyNames.some(isKdeDesktopName); - } - - return isSet(env.KDE_SESSION_VERSION) || isAffirmativeFlag(env.KDE_FULL_SESSION); -} - -function isKdeDesktopName(name: string): boolean { - return KDE_NAME_PREFIXES.some((prefix) => name.startsWith(prefix)); -} - -function legacyDesktopNames(env: NodeJS.ProcessEnv): string[] { - return [env.XDG_SESSION_DESKTOP, env.DESKTOP_SESSION, env.GDMSESSION].flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function nonEmptyDesktopNames(value: string | undefined): string[] { - return splitDesktopNameList(value).flatMap((entry) => { - const normalized = normalizeDesktopName(entry); - return normalized ? [normalized] : []; - }); -} - -function isSet(value: string | undefined): boolean { - return Boolean(value?.trim()); -} - -function isAffirmativeFlag(value: string | undefined): boolean { - const normalized = value?.trim().toLowerCase(); - return normalized ? !NEGATIVE_FLAG_VALUES.has(normalized) : false; -} - function splitDesktopNameList(value: string | undefined): string[] { return value?.split(":") ?? []; } - -function normalizeDesktopName(value: string | undefined): string | null { - const normalized = value?.trim().toLowerCase(); - return normalized && normalized.length > 0 ? normalized : null; -} - -function normalizeSelectedStorageBackend(value: string | null): string | null { - const normalized = value?.trim().toLowerCase().replace(/_/gu, "-"); - return normalized && normalized.length > 0 ? normalized : null; -} From a9caf7b7089afb6f1b3b888ea404b75b8f9e88e1 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:43 -0700 Subject: [PATCH 031/320] refactor(desktop): remove test-only Electron error predicates (#9982) --- apps/desktop/src/electron/ElectronDialog.test.ts | 1 - apps/desktop/src/electron/ElectronDialog.ts | 1 - apps/desktop/src/electron/ElectronTheme.test.ts | 1 - apps/desktop/src/electron/ElectronTheme.ts | 2 -- apps/desktop/src/electron/ElectronUpdater.test.ts | 3 --- apps/desktop/src/electron/ElectronUpdater.ts | 1 - apps/desktop/src/electron/ElectronWindow.test.ts | 1 - apps/desktop/src/electron/ElectronWindow.ts | 2 -- 8 files changed, 12 deletions(-) diff --git a/apps/desktop/src/electron/ElectronDialog.test.ts b/apps/desktop/src/electron/ElectronDialog.test.ts index 3acaf7154508..2ed5a1f2f913 100644 --- a/apps/desktop/src/electron/ElectronDialog.test.ts +++ b/apps/desktop/src/electron/ElectronDialog.test.ts @@ -43,7 +43,6 @@ describe("ElectronDialog", () => { ); assert.instanceOf(error, ElectronDialog.ElectronDialogPickFolderError); - assert.isTrue(ElectronDialog.isElectronDialogError(error)); assert.strictEqual(error.ownerWindowId, 7); assert.strictEqual(error.defaultPath, "/workspace"); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/electron/ElectronDialog.ts b/apps/desktop/src/electron/ElectronDialog.ts index 4300d9ab0d39..30ca73a5e143 100644 --- a/apps/desktop/src/electron/ElectronDialog.ts +++ b/apps/desktop/src/electron/ElectronDialog.ts @@ -73,7 +73,6 @@ export const ElectronDialogError = Schema.Union([ ElectronDialogShowErrorBoxError, ]); export type ElectronDialogError = typeof ElectronDialogError.Type; -export const isElectronDialogError = Schema.is(ElectronDialogError); export interface ElectronDialogPickFolderInput { readonly owner: Option.Option; diff --git a/apps/desktop/src/electron/ElectronTheme.test.ts b/apps/desktop/src/electron/ElectronTheme.test.ts index 4b81943eff2b..b4028930af66 100644 --- a/apps/desktop/src/electron/ElectronTheme.test.ts +++ b/apps/desktop/src/electron/ElectronTheme.test.ts @@ -64,7 +64,6 @@ describe("ElectronTheme", () => { const error = yield* Effect.flip(electronTheme.setSource("dark")); assert.instanceOf(error, ElectronTheme.ElectronThemeSetSourceError); - assert.isTrue(ElectronTheme.isElectronThemeSetSourceError(error)); assert.strictEqual(error.source, "dark"); assert.strictEqual(error.cause, cause); assert.include(error.message, "dark"); diff --git a/apps/desktop/src/electron/ElectronTheme.ts b/apps/desktop/src/electron/ElectronTheme.ts index ef47e3d0954f..24b2d856b9d2 100644 --- a/apps/desktop/src/electron/ElectronTheme.ts +++ b/apps/desktop/src/electron/ElectronTheme.ts @@ -19,8 +19,6 @@ export class ElectronThemeSetSourceError extends Schema.TaggedErrorClass { const error = yield* updater.checkForUpdates.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterCheckForUpdatesError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "beta"); assert.strictEqual(error.cause, cause); assert.equal(error.message, "Electron updater failed to check for updates on channel beta."); @@ -89,7 +88,6 @@ describe("ElectronUpdater", () => { const error = yield* updater.downloadUpdate.pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterDownloadUpdateError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, cause); assert.equal( @@ -126,7 +124,6 @@ describe("ElectronUpdater", () => { .pipe(Effect.flip); assert.instanceOf(error, ElectronUpdater.ElectronUpdaterQuitAndInstallError); - assert.isTrue(ElectronUpdater.isElectronUpdaterError(error)); assert.equal(error.channel, "alpha"); assert.equal(error.isSilent, true); assert.equal(error.isForceRunAfter, false); diff --git a/apps/desktop/src/electron/ElectronUpdater.ts b/apps/desktop/src/electron/ElectronUpdater.ts index 4157d29a9df8..8e044de65ad6 100644 --- a/apps/desktop/src/electron/ElectronUpdater.ts +++ b/apps/desktop/src/electron/ElectronUpdater.ts @@ -54,7 +54,6 @@ export const ElectronUpdaterError = Schema.Union([ ElectronUpdaterQuitAndInstallError, ]); export type ElectronUpdaterError = typeof ElectronUpdaterError.Type; -export const isElectronUpdaterError = Schema.is(ElectronUpdaterError); export class ElectronUpdater extends Context.Service< ElectronUpdater, diff --git a/apps/desktop/src/electron/ElectronWindow.test.ts b/apps/desktop/src/electron/ElectronWindow.test.ts index bebb0e5c4178..c802e595633a 100644 --- a/apps/desktop/src/electron/ElectronWindow.test.ts +++ b/apps/desktop/src/electron/ElectronWindow.test.ts @@ -79,7 +79,6 @@ describe("ElectronWindow", () => { const error = yield* electronWindow.create(options).pipe(Effect.flip); assert.instanceOf(error, ElectronWindow.ElectronWindowCreateError); - assert.isTrue(ElectronWindow.isElectronWindowCreateError(error)); assert.deepEqual(error.options, { title: "T3 Code", width: 1100, diff --git a/apps/desktop/src/electron/ElectronWindow.ts b/apps/desktop/src/electron/ElectronWindow.ts index 5f6a9d34280b..9234399191cf 100644 --- a/apps/desktop/src/electron/ElectronWindow.ts +++ b/apps/desktop/src/electron/ElectronWindow.ts @@ -58,8 +58,6 @@ export class ElectronWindowCreateError extends Schema.TaggedErrorClass()( "ElectronWindowOperationError", { From ea0487cc9adf9f51c865fe39128102afc532bcfc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:12:47 -0700 Subject: [PATCH 032/320] refactor(web): remove unused pull request state label (#9984) --- .../pullRequest/pullRequestDetail.logic.test.ts | 10 ---------- .../components/pullRequest/pullRequestDetail.logic.ts | 7 ------- 2 files changed, 17 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index c51429ff6d83..61c630c815e4 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -38,7 +38,6 @@ import { shouldRefreshPullRequestActivity, resolveBaseFreshness, buildPullRequestTimeline, - describePullRequestState, editPullRequestThreadComment, writePullRequestDetailSnapshot, } from "./pullRequestDetail.logic"; @@ -199,15 +198,6 @@ describe("pull request primary control", () => { }); }); -describe("pull request state description", () => { - it("keeps draft and conflicts orthogonal to the terminal states", () => { - expect(describePullRequestState("open", true)).toBe("Draft"); - expect(describePullRequestState("open", false)).toBe("Ready for review"); - expect(describePullRequestState("merged", true)).toBe("Merged"); - expect(describePullRequestState("closed", false)).toBe("Closed"); - }); -}); - describe("pull request handoff labels", () => { it("names the open thread when actions write to its composer", () => { expect(pullRequestHandoffLabels(true)).toEqual({ diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index cffe33f8d83d..d00215f02d41 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -189,13 +189,6 @@ export function isStackedPullRequestBase( return defaultBranch !== baseBranch; } -/** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ -export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { - if (state === "merged") return "Merged"; - if (state === "closed") return "Closed"; - return isDraft ? "Draft" : "Ready for review"; -} - /** Chronological ascending, oldest to newest — reversed for the "newest" reading order. */ export function orderPullRequestComments( comments: ReadonlyArray, From aefef95ffc34eb76c8e2db9a219b86681f644576 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:28 -0700 Subject: [PATCH 033/320] perf(web): keep timeline row reuse engaged while text streams (#9909) Co-authored-by: Claude Fable 5 --- .../web/src/components/ChatView.logic.test.ts | 50 ++++++++++++++++ apps/web/src/components/ChatView.logic.ts | 30 ++++++---- apps/web/src/components/ChatView.tsx | 26 +++++---- .../chat/MessagesTimeline.logic.test.ts | 58 ++++++++++++++++++- 4 files changed, 141 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index c67ea3f3d5c3..a5a8503985b2 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1089,6 +1089,56 @@ describe("buildRevertTurnCountByUserMessageId", () => { }).size, ).toBe(0); }); + + it.each([true, false])( + "returns the previous map when contents are unchanged (rollback supported: %s)", + (supportsConversationRollback) => { + const input = { + supportsConversationRollback, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }; + const previous = buildRevertTurnCountByUserMessageId(input); + const streamed = timelineEntries.map((entry) => + entry.message.role === "assistant" + ? { ...entry, message: { ...entry.message, text: "Updated the file again" } } + : entry, + ); + + expect( + buildRevertTurnCountByUserMessageId({ ...input, timelineEntries: streamed }, previous), + ).toBe(previous); + }, + ); + + it("returns a new map when a revert target changes", () => { + const input = { + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: {}, + }; + const previous = buildRevertTurnCountByUserMessageId(input); + const next = buildRevertTurnCountByUserMessageId( + { + ...input, + turnDiffSummaryByAssistantMessageId: new Map([ + [ + assistantMessageId, + { + ...turnDiffSummaryByAssistantMessageId.get(assistantMessageId)!, + checkpointTurnCount: 3, + }, + ], + ]), + }, + previous, + ); + + expect(next).not.toBe(previous); + expect(next).toEqual(new Map([[userMessageId, 2]])); + }); }); describe("deriveComposerSendState", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 10c2fd4710ee..bf576a3c7635 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,6 +38,7 @@ import { } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; +import { shallow } from "zustand/vanilla/shallow"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import { @@ -464,17 +465,24 @@ export function getAntigravitySendBlockReason( return null; } -export function buildRevertTurnCountByUserMessageId(input: { - supportsConversationRollback: boolean; - timelineEntries: ReadonlyArray; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - inferredCheckpointTurnCountByTurnId: Readonly>; -}) { +/** + * Maps each user message to the checkpoint turn count a revert should target. + * Returns `previous` when the result is unchanged: streaming text deltas + * rebuild `timelineEntries` per token, and the timeline row projection only + * reuses rows while this Map keeps its identity. + */ +export function buildRevertTurnCountByUserMessageId( + input: { + supportsConversationRollback: boolean; + timelineEntries: ReadonlyArray; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + inferredCheckpointTurnCountByTurnId: Readonly>; + }, + previous: Map | null = null, +): Map { const byUserMessageId = new Map(); - if (!input.supportsConversationRollback) { - return byUserMessageId; - } - for (let index = 0; index < input.timelineEntries.length; index += 1) { + const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; + for (let index = 0; index < entryCount; index += 1) { const entry = input.timelineEntries[index]; if (!entry || entry.kind !== "message" || entry.message.role !== "user") { continue; @@ -501,7 +509,7 @@ export function buildRevertTurnCountByUserMessageId(input: { break; } } - return byUserMessageId; + return previous !== null && shallow(previous, byUserMessageId) ? previous : byUserMessageId; } export function reconcileMountedTerminalThreadIds(input: { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4ceba195e98b..b27d66c7611d 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2971,21 +2971,25 @@ export default function ChatView(props: ChatViewProps) { } return byMessageId; }, [turnDiffSummaries]); - const revertTurnCountByUserMessageId = useMemo( - () => - buildRevertTurnCountByUserMessageId({ + const lastRevertTurnCountRef = useRef | null>(null); + const revertTurnCountByUserMessageId = useMemo(() => { + const next = buildRevertTurnCountByUserMessageId( + { supportsConversationRollback, timelineEntries, turnDiffSummaryByAssistantMessageId, inferredCheckpointTurnCountByTurnId, - }), - [ - supportsConversationRollback, - inferredCheckpointTurnCountByTurnId, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - ], - ); + }, + lastRevertTurnCountRef.current, + ); + lastRevertTurnCountRef.current = next; + return next; + }, [ + supportsConversationRollback, + inferredCheckpointTurnCountByTurnId, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + ]); const gitCwd = activeProject ? projectScriptCwd({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 94727086f186..5d310c5fe345 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vite-plus/test"; -import { MessageId, TurnId } from "@t3tools/contracts"; +import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, @@ -20,6 +20,7 @@ import { deriveTimelineEntriesWithState, type WorkLogEntry, } from "../../session-logic"; +import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic"; import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; describe("streaming row projection", () => { @@ -242,6 +243,61 @@ describe("streaming row projection", () => { }, ); + it("reuses rows when the revert map is rebuilt from the streamed entries", () => { + const initial = fixture("Partial"); + const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 }; + const turnDiffSummaryByAssistantMessageId = new Map([ + [ + MessageId.make("history-assistant"), + { + turnId: initial.historyTurnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), + status: "ready", + files: [], + assistantMessageId: MessageId.make("history-assistant"), + completedAt: initial.time(4), + }, + ], + ]); + let revertMap: Map | null = null; + // Mirrors ChatView: the map is derived from each delta's entries. + const build = (timelineEntries: typeof initial.timeline.entries) => { + revertMap = buildRevertTurnCountByUserMessageId( + { + supportsConversationRollback: true, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId, + }, + revertMap, + ); + return { + ...initial.input, + timelineEntries, + turnDiffSummaryByAssistantMessageId, + revertTurnCountByUserMessageId: revertMap, + }; + }; + const previous = deriveMessagesTimelineRowsWithState(build(initial.timeline.entries)); + expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe( + true, + ); + const last = initial.messages.at(-1)!; + const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }]; + const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); + const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous); + + expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries))); + for (const [index, row] of previous.rows.entries()) { + if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { + expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } }); + } else { + expect(next.rows[index]).toBe(row); + } + } + }); + it.each(["completion", "turn", "role", "ordering"] as const)( "rebuilds row structure for a %s change with otherwise unchanged controls", (change) => { From 3e1333319aacff96856a3c161b8787ff6b0ddd6b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:32 -0700 Subject: [PATCH 034/320] fix(web): reset markdown widgets when the previewed file changes (#9910) Co-authored-by: Claude Fable 5 --- apps/web/src/components/files/FilePreviewPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 3f0c92742ca7..33d4d9a4b6cf 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -1266,7 +1266,11 @@ export default function FilePreviewPanel({
) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( + // Markdown reconciles in place across text updates, so a file + // switch needs a new key or the previous file's disclosure and + // wrap state carries into the next document. Date: Sat, 5 Sep 2026 00:15:36 -0700 Subject: [PATCH 035/320] fix(mobile): keep highlighting review diffs after a long line (#9911) Co-authored-by: Claude Fable 5 --- .../diffs/nativeReviewDiffHighlighter.test.ts | 36 +++++++++++++++---- .../diffs/nativeReviewDiffHighlighter.ts | 17 ++++----- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index 00679afa4a63..e7e75a4faa1a 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -257,13 +257,13 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); }); - it("keeps long lines and unknown following syntax plain until the next hunk", async () => { + it("keeps only the long line plain and resumes highlighting after it", async () => { const longLine = `${"x".repeat(1_001)} /*`; const rows = [ line(1, "export const before = 1;"), line(2, longLine), { kind: "comment", id: "note", commentText: "Check this", fileId: TYPESCRIPT_FILE.id }, - line(3, "inside the comment */"), + line(3, "export const inside = 'x';"), makeHunk("next-hunk"), line(100, "export const after = 2;"), ] satisfies ReadonlyArray; @@ -274,14 +274,36 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.tokensByRowId["line-2"]).toEqual([ { content: longLine, color: null, fontStyle: null }, ]); - expect(result.tokensByRowId["line-3"]).toEqual([ - { content: "inside the comment */", color: null, fontStyle: null }, - ]); - expect(result.tokensByRowId["line-1"]?.some((token) => token.color !== null)).toBe(true); - expect(result.tokensByRowId["line-100"]?.some((token) => token.color !== null)).toBe(true); + for (const id of ["line-1", "line-3", "line-100"]) { + expect(result.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + } expect(tokenization.calls.some((code) => code.includes(longLine))).toBe(false); }); + it("highlights the rows after a long line the same regardless of the first window", async () => { + const rows = [ + line(1, "export const before = 1;"), + line(2, `const data = "${"x".repeat(1_050)}";`), + line(3, "export const inside = 'x';"), + line(4, "export const after = 2;"), + ]; + const spanning = await highlightRows(rows); + const afterLongLine = await highlightNativeReviewDiffVisibleRows({ + rows, + files: [TYPESCRIPT_FILE], + scheme: "dark", + engine, + firstRowIndex: 2, + lastRowIndex: 3, + overscanRows: 0, + }); + + for (const id of ["line-3", "line-4"]) { + expect(spanning.tokensByRowId[id]?.some((token) => token.color !== null)).toBe(true); + expect(afterLongLine.tokensByRowId[id]).toEqual(spanning.tokensByRowId[id]); + } + }); + it("preserves multiline grammar and row mapping across character-limited batches", async () => { const opening = line(1, "const message = `open"); const body = Array.from({ length: 40 }, (_, index) => line(index + 2, "inside ".repeat(45))); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 383e1e73a85f..0ea8c100dc11 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -247,15 +247,16 @@ function createHighlighterHandle( while (start < lines.length) { if (signal?.aborted) return []; - // Skipping this line leaves its ending grammar state unknown. Keep the - // rest of this contiguous segment plain instead of guessing its syntax. + // Skipping this line leaves its ending grammar state unknown. Resume + // from a fresh state rather than leaving the rest of the segment plain: + // highlighted rows are cached for the sheet's lifetime, so a plain tail + // would stick, and which rows it covered would depend on where the + // first visible window happened to start. if (lines[start]!.length > NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH) { - highlighted.push( - ...lines - .slice(start) - .map((content) => [{ content: content || " ", color: null, fontStyle: null }]), - ); - break; + highlighted.push([{ content: lines[start] || " ", color: null, fontStyle: null }]); + grammarState = undefined; + start += 1; + continue; } let end = start; From fc1f543d6c67da9cf1edb25b647646faa0105b41 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:41 -0700 Subject: [PATCH 036/320] fix(marketing): align the endorsement carousel with its heading (#9912) Co-authored-by: Claude Fable 5 --- apps/marketing/src/pages/index.astro | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index a4fdc966b9d8..723ad4d4324b 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -826,8 +826,11 @@ const screenshot = await getImage({ gap: 12px; overflow-x: auto; overscroll-behavior-x: contain; - padding: 4px max(32px, calc((100vw - 1240px) / 2 + 32px)); - scroll-padding-inline: max(32px, calc((100vw - 1240px) / 2 + 32px)); + /* Percentages resolve against this element's own box rather than the + viewport, so the first card lines up with the heading's .container edge + even when a classic scrollbar makes 100vw wider than the layout. */ + padding: 4px max(32px, calc((100% - 1240px) / 2 + 32px)); + scroll-padding-inline: max(32px, calc((100% - 1240px) / 2 + 32px)); } .endorsement-card { From f87ecf0cc307f974745da8eea856fab5711023f3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:15:46 -0700 Subject: [PATCH 037/320] fix(client): keep warm thread resumes live instead of flashing sync (#9913) Co-authored-by: Claude Fable 5 --- .../src/state/threads-atoms.test.ts | 91 ++++++++++++++++++- packages/client-runtime/src/state/threads.ts | 24 ++++- 2 files changed, 110 insertions(+), 5 deletions(-) diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index d7a415b2999d..27229a7aff61 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -27,6 +27,7 @@ import { PrimaryConnectionTarget, type NetworkStatus, type PreparedConnection, + type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; @@ -73,8 +74,18 @@ const THREAD: OrchestrationThread = { }; const SNAPSHOT: OrchestrationThreadDetailSnapshot = { snapshotSequence: 7, thread: THREAD }; +const CONNECTED_STATE: SupervisorConnectionState = { + ...AVAILABLE_CONNECTION_STATE, + desired: true, + network: "online", + phase: "connected", + attempt: 1, + generation: 1, +}; + const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationThreadDetailSnapshot; + readonly connected?: boolean; }) { const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; @@ -123,10 +134,14 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? probe: Effect.void, closed: Effect.never, }; + const connectionState = yield* SubscriptionRef.make( + options?.connected ? CONNECTED_STATE : AVAILABLE_CONNECTION_STATE, + ); + const sessionRef = yield* SubscriptionRef.make(Option.some(session)); const supervisor = EnvironmentSupervisor.of({ target: TARGET, - state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), - session: yield* SubscriptionRef.make(Option.some(session)), + state: connectionState, + session: sessionRef, prepared: yield* SubscriptionRef.make>( Option.some({ environmentId: TARGET.environmentId, @@ -228,6 +243,9 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? ref, subscriptions, olderLoads, + connectionState, + session, + sessionRef, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -319,6 +337,75 @@ describe("createEnvironmentThreadStateAtoms", () => { }), ); + it.effect.each([ + { replayed: false, statuses: ["live"] }, + { replayed: true, statuses: ["live", "synchronizing", "live"] }, + ])( + "keeps a warm resume live until it replays events (replayed: $replayed)", + ({ replayed, statuses }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + const observed: Array = []; + const stop = h.registry.subscribe(h.stateAtom, (state) => observed.push(state.status), { + immediate: true, + }); + const remount = h.registry.mount(h.stateAtom); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + if (replayed) { + yield* Queue.offer(next.events, { + kind: "snapshot", + snapshot: { snapshotSequence: 9, thread: { ...THREAD, title: "Replayed" } }, + }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "synchronizing"); + } + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + expect(observed.filter((status, index) => observed[index - 1] !== status)).toEqual( + statuses, + ); + stop(); + remount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("downgrades a warm resume when the connection dropped while away", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.offer(first.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(first.closed); + + yield* SubscriptionRef.set(h.sessionRef, Option.none()); + yield* SubscriptionRef.set(h.connectionState, AVAILABLE_CONNECTION_STATE); + const remount = h.registry.mount(h.stateAtom); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "cached"); + expect(currentThread(h.registry, h.stateAtom)).toBe(THREAD); + expect(h.counts().opened).toBe(1); + + yield* SubscriptionRef.set(h.connectionState, CONNECTED_STATE); + yield* SubscriptionRef.set(h.sessionRef, Option.some(h.session)); + const next = yield* Queue.take(h.subscriptions); + expect(next.afterSequence).toBe(7); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + remount(); + yield* Deferred.await(next.closed); + }), + ); + it.effect("keeps warm data when the raw atom family's weak entry is collected", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index a0055b6cab3c..cdd07087d06a 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -158,10 +158,18 @@ function matchesThreadSnapshot( currentPage.hasMore === page.hasMore; } +// A retained "live" state stays live: the cursor resume that follows only +// replays what the thread missed, and on servers that send the completion +// marker the first replayed event moves the status to "synchronizing" on its +// own. Downgrading here would flash a sync label on every return to a +// recently viewed thread. function cachedThreadState(value: EnvironmentThreadState): EnvironmentThreadState { return { ...value, - status: value.status === "deleted" ? "deleted" : statusWithoutLiveData(value.data), + status: + value.status === "deleted" || (value.status === "live" && Option.isSome(value.data)) + ? value.status + : statusWithoutLiveData(value.data), error: Option.none(), page: Option.map(value.page, (page) => ({ ...page, loadingOlder: false })), }; @@ -647,7 +655,16 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make service.changes.pipe(Stream.filter(ConnectionWakeups.shouldResubscribeAfterWakeup)), }); - yield* setSynchronizing; + // Only the first subscription after a warm live resume keeps the retained + // status. A replacement session or foreground resubscribe on the same scope + // may have missed events, so those show sync progress until confirmed. + const resumingLive = yield* Ref.make(initialState.status === "live"); + const markSynchronizing = Effect.gen(function* () { + if (yield* Ref.get(resumingLive)) return; + yield* setSynchronizing; + }); + + yield* markSynchronizing; yield* Effect.forkScoped( subscribeDynamic( ORCHESTRATION_WS_METHODS.subscribeThread, @@ -668,7 +685,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const supportsPagination = config.threadSnapshotPagination === true; yield* Ref.set(paginationSupported, supportsPagination); yield* Ref.set(awaitingCompletion, supportsCompletionMarker); - yield* setSynchronizing; + yield* markSynchronizing; + yield* Ref.set(resumingLive, false); let current = yield* SubscriptionRef.get(state); // A windowed cache resuming against a server without pagination is a From 371e32392570fdc0d2309e9f92490bbd2c6b3f02 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:14 -0700 Subject: [PATCH 038/320] test(server): remove authorization prompt snapshots (#9985) --- apps/server/src/cli/connect.test.ts | 19 ------------------- apps/server/src/cli/connect.ts | 4 ++-- apps/server/src/cloud/CliTokenManager.test.ts | 13 ------------- apps/server/src/cloud/CliTokenManager.ts | 2 +- 4 files changed, 3 insertions(+), 35 deletions(-) diff --git a/apps/server/src/cli/connect.test.ts b/apps/server/src/cli/connect.test.ts index 1e0c88c24e84..f05eeab631f6 100644 --- a/apps/server/src/cli/connect.test.ts +++ b/apps/server/src/cli/connect.test.ts @@ -13,31 +13,12 @@ import * as Terminal from "effect/Terminal"; import * as BootService from "../cloud/bootService.ts"; import { acquireRelayClientForLink, - formatHeadlessAuthorizationPrompt, - formatRelayClientReady, headlessSessionConfig, isPublishAgentActivityEnabledValue, reportCloudDisconnectResults, } from "./connect.ts"; import { recoverServiceOnboardingOffer } from "./service.ts"; -it("explains how to complete headless authorization", () => { - assert.equal( - formatHeadlessAuthorizationPrompt("https://example.test/connect"), - [ - "Headless authorization", - "Open this URL on a device with a browser:", - " https://example.test/connect", - "", - "After signing in, return here and enter the code shown in your browser.", - ].join("\n"), - ); -}); - -it("formats relay readiness without printing its installation path", () => { - assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2"); -}); - const readHeadlessSessionConfig = (env: Record) => headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env })))); diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 25cfb18f3402..faa8f69871df 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -86,7 +86,7 @@ const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_ }, ); -export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { +function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string { return [ "Headless authorization", "Open this URL on a device with a browser:", @@ -464,7 +464,7 @@ const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* (identity ? ` as ${identity}` : ""); -export function formatRelayClientReady(version: string): string { +function formatRelayClientReady(version: string): string { return `✓ Relay client ready · cloudflared ${version}`; } diff --git a/apps/server/src/cloud/CliTokenManager.test.ts b/apps/server/src/cloud/CliTokenManager.test.ts index e6eb6b7cd6fd..33a0f1224961 100644 --- a/apps/server/src/cloud/CliTokenManager.test.ts +++ b/apps/server/src/cloud/CliTokenManager.test.ts @@ -93,19 +93,6 @@ class PromptRejectedError extends Schema.TaggedErrorClass() { message: Schema.String }, ) {} -it("formats loopback authorization with a headless-host fallback", () => { - assert.equal( - CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"), - [ - "Open this URL to authorize T3 Connect:", - " https://clerk.example.test/authorize", - "", - "Press \u001b[1mEnter\u001b[22m to open it in your browser.", - "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.", - ].join("\n"), - ); -}); - const makeTestTerminal = (queue: Queue.Queue) => Terminal.make({ columns: Effect.succeed(80), diff --git a/apps/server/src/cloud/CliTokenManager.ts b/apps/server/src/cloud/CliTokenManager.ts index c4443a7301cb..8c3869accc76 100644 --- a/apps/server/src/cloud/CliTokenManager.ts +++ b/apps/server/src/cloud/CliTokenManager.ts @@ -44,7 +44,7 @@ const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10); const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5)); const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`; -export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { +function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string { return [ "Open this URL to authorize T3 Connect:", ` ${authorizationUrl}`, From 10421bcdc9a059e0a717250257769af92567b645 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 00:18:18 -0700 Subject: [PATCH 039/320] test(server): remove static OAuth page snapshots (#9986) --- apps/server/src/cloud/cliAuthHtml.test.ts | 31 ----------------------- apps/server/src/cloud/cliAuthHtml.ts | 2 +- 2 files changed, 1 insertion(+), 32 deletions(-) delete mode 100644 apps/server/src/cloud/cliAuthHtml.test.ts diff --git a/apps/server/src/cloud/cliAuthHtml.test.ts b/apps/server/src/cloud/cliAuthHtml.test.ts deleted file mode 100644 index 1104927b9800..000000000000 --- a/apps/server/src/cloud/cliAuthHtml.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { expect, it } from "@effect/vitest"; - -import { - renderLoopbackAuthorizationCompleteHtml, - resolveLoopbackAuthorizationStage, -} from "./cliAuthHtml.ts"; - -it("renders the branded loopback authorization completion page", () => { - const html = renderLoopbackAuthorizationCompleteHtml(); - - expect(resolveLoopbackAuthorizationStage()).toBe("dev"); - expect(html).toContain("T3 Code (Dev)"); - expect(html).toContain('class="stage stage-dev"'); - expect(html).not.toContain("Secure terminal handoff"); - expect(html).toContain("You're connected"); - expect(html).toContain("Return to your terminal"); - expect(html).not.toContain('class="next"'); - expect(html).toContain('name="viewport"'); - expect(html).not.toContain('class="status"'); -}); - -it("renders the matching header treatment for each release channel", () => { - const nightly = renderLoopbackAuthorizationCompleteHtml("nightly"); - const latest = renderLoopbackAuthorizationCompleteHtml("latest"); - - expect(nightly).toContain("T3 Code (Nightly)"); - expect(nightly).toContain('class="stage stage-nightly"'); - expect(latest).toContain('

T3 Code

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

logo

', ); - const centeredClassName = /]*class="([^"]*)"/.exec(centeredHtml)?.[1]; + const frame = /]*role="status"[^>]*>/.exec(centeredHtml)?.[0]; + + expect(frame).toContain("inline-block!"); + expect(frame).toContain("aspect-video"); + }); + + it("reserves a slot for an image that is the only content of its link", () => { + const html = render("[![shot](.t3/workspace-image.svg)](https://example.com)"); + + expect(html).toContain("aspect-video"); + }); + + it.each([ + ["a link", "Figure: [![shot](.t3/workspace-image.svg)](https://example.com)"], + ["emphasis", "**![shot](.t3/workspace-image.svg)** caption"], + ])("keeps an image wrapped in %s inline when text shares its block", (_wrapper, markdown) => { + expect(render(markdown)).not.toContain("aspect-video"); + }); + + it("keeps an authored id on a remote image so fragment links resolve", () => { + const html = render('diagram'); + + // The sanitizer prefixes authored ids; the loading slot carries it too. + expect(html).toContain(' { + expect(render("- ![shot](.t3/workspace-image.svg)")).toContain("aspect-video"); }); it("retains an authored SVG fragment on the signed URL", () => { @@ -278,15 +305,35 @@ describe("ChatMarkdown workspace images", () => { ); }); - it("uses a static bounded-width placeholder while a signed asset URL loads", () => { + it("reserves the same 16:9 frame while the URL, the bytes, and a failure resolve", () => { + const frameClassName = (html: string) => { + const frame = /]*role="(?:status|alert)"[^>]*>/.exec(html)?.[0] ?? ""; + return /class="([^"]*)"/.exec(frame)?.[1]?.split(" ") ?? []; + }; + const markdown = "![shot](.t3/workspace-image.svg)"; + testState.assetState = "loading"; + const loadingUrl = frameClassName(render(markdown)); + testState.assetState = "success"; + const loadingBytes = render(markdown); + testState.assetState = "failure"; + const failure = render(markdown); + + expect(loadingUrl).toEqual(expect.arrayContaining(["aspect-video", "w-full"])); + expect(loadingUrl).not.toContain("animate-pulse"); + expect(frameClassName(loadingBytes)).toEqual(loadingUrl); + expect(frameClassName(failure)).toEqual(loadingUrl); + expect(failure).toContain("Image unavailable"); + // The bytes are requested inside the frame but never paint at an unknown size. + expect(loadingBytes).toMatch(/]*src="https:\/\/signed[^>]*class="invisible/); + expect(loadingBytes).not.toContain('loading="lazy"'); + }); - const html = render("![loading](.t3/workspace-image.svg)"); - const className = /]*aria-label="Loading image"[^>]*class="([^"]*)"/.exec(html)?.[1]; + it("gives a standalone remote image the same frame instead of a bare tag", () => { + const html = render("![remote](https://example.com/shot.png)"); expect(html).toContain('aria-label="Loading image"'); - expect(html).not.toContain("animate-pulse"); - expect(className?.split(" ")).toContain("w-64"); + expect(html).toContain("aspect-video"); }); it("never passes a workspace source to a raw image when thread context is unavailable", () => { @@ -313,7 +360,6 @@ describe("ChatMarkdown workspace images", () => { expect(testState.resources).toEqual([]); expect(html).toContain('src="https://example.com/image.png"'); expect(html).toContain("max-w-[min(100%,30rem)]"); - expect(html).toContain("max-h-[30rem]"); expect(html).not.toContain("Image unavailable"); }); }); diff --git a/apps/web/src/components/media/MediaVideoPlayer.tsx b/apps/web/src/components/media/MediaVideoPlayer.tsx index f436beeb3855..90c18984e17b 100644 --- a/apps/web/src/components/media/MediaVideoPlayer.tsx +++ b/apps/web/src/components/media/MediaVideoPlayer.tsx @@ -127,7 +127,9 @@ export function MediaVideoPlayer({ From 31fb21009024e9476bda4705355d55491d7fd2ef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:19:37 -0700 Subject: [PATCH 074/320] refactor(server): keep manifest age parsing private (#10028) --- apps/server/src/provider/ModelManifest.test.ts | 2 -- apps/server/src/provider/ModelManifest.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index 73049ad01c30..bb592a0a6fc8 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -17,7 +17,6 @@ import { make, resolveProviderCatalog, type ModelManifestData, - manifestUpdatedAtMs, encodeManifestCache, } from "./ModelManifest.ts"; @@ -382,7 +381,6 @@ describe("ModelManifest service", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const config = yield* ServerConfig.ServerConfig; - assert.isAbove(manifestUpdatedAtMs(BUNDLED_MODEL_MANIFEST), 0); const cachePath = path.join(config.stateDir, "model-manifest.json"); // A cache of the manifest as it was before the release edited it. The // fetch time is irrelevant: the remote may be unreachable now, so diff --git a/apps/server/src/provider/ModelManifest.ts b/apps/server/src/provider/ModelManifest.ts index c3cb36566c02..67a7334f613d 100644 --- a/apps/server/src/provider/ModelManifest.ts +++ b/apps/server/src/provider/ModelManifest.ts @@ -138,7 +138,7 @@ export const BUNDLED_MODEL_MANIFEST: ModelManifestData = Schema.decodeUnknownSync(ModelManifestSchema)(bundledManifestJson); /** Epoch millis of the manifest's `updatedAt`, or 0 when absent or unparsable. */ -export function manifestUpdatedAtMs(manifest: ModelManifestData): number { +function manifestUpdatedAtMs(manifest: ModelManifestData): number { if (manifest.updatedAt === undefined) return 0; const parsed = Date.parse(manifest.updatedAt); return Number.isNaN(parsed) ? 0 : parsed; From 37bf4e6ec4f18fb94e7ec04436a947d14642a5b6 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:19:42 -0700 Subject: [PATCH 075/320] refactor(mobile): remove unused awareness relay URL normalizer (#10029) --- .../agent-awareness/remoteRegistration.test.ts | 8 -------- .../src/features/agent-awareness/remoteRegistration.ts | 10 ---------- 2 files changed, 18 deletions(-) diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts index 582c58fb27e6..152948274ca3 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.test.ts @@ -33,7 +33,6 @@ import { mergeAgentAwarenessRegistrationPreferences, refreshActiveLiveActivityRemoteRegistration, refreshAgentAwarenessRegistration, - normalizeAgentAwarenessRelayBaseUrl, registerAgentAwarenessConnection, registerLiveActivityPushToken, releaseAgentAwarenessRelayTokenProvider, @@ -363,13 +362,6 @@ describe("makeRelayDeviceRegistrationRequest", () => { }); }); - it("normalizes relay base URLs for APNs registration requests", () => { - expect(normalizeAgentAwarenessRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeAgentAwarenessRelayBaseUrl(" ")).toBeNull(); - }); - it("overrides persisted preferences for an in-flight registration", () => { expect( mergeAgentAwarenessRegistrationPreferences( diff --git a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts index a2d4261de603..9f4539c44d64 100644 --- a/apps/mobile/src/features/agent-awareness/remoteRegistration.ts +++ b/apps/mobile/src/features/agent-awareness/remoteRegistration.ts @@ -139,16 +139,6 @@ export function mergeAgentAwarenessRegistrationPreferences( return { ...stored, ...override }; } -export function normalizeAgentAwarenessRelayBaseUrl( - value: string | null | undefined, -): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayConfig(): { readonly url: string } | null { const relayUrl = resolveCloudPublicConfig().relay.url; if (!relayUrl) { From f5d9d12026d98b2b4b975f1944e7503d6c2c9eb3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:19:47 -0700 Subject: [PATCH 076/320] refactor(server): remove unused startup heartbeat launcher (#10030) --- apps/server/src/serverRuntimeStartup.test.ts | 51 -------------------- apps/server/src/serverRuntimeStartup.ts | 8 --- 2 files changed, 59 deletions(-) diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 0b909d96f43d..fddf618cb13b 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -13,7 +13,6 @@ import * as Stream from "effect/Stream"; import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; -import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -103,56 +102,6 @@ it.effect("enqueueCommand fails queued work when readiness fails", () => ), ); -it.effect("launchStartupHeartbeat does not block the caller while counts are loading", () => - Effect.scoped( - Effect.gen(function* () { - const releaseCounts = yield* Deferred.make(); - const countsStarted = yield* Deferred.make(); - - yield* ServerRuntimeStartup.launchStartupHeartbeat.pipe( - Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { - getUserInputActivity: () => Effect.die("unused"), - getCommandReadModel: () => Effect.die("unused"), - getSnapshot: () => Effect.die("unused"), - getShellSnapshot: () => Effect.die("unused"), - getArchivedShellSnapshot: () => Effect.die("unused"), - getSnapshotSequence: () => Effect.die("unused"), - getEventReplayStats: () => Effect.die("unused"), - getCounts: () => - Deferred.succeed(countsStarted, undefined).pipe( - Effect.andThen(Deferred.await(releaseCounts)), - Effect.as({ - projectCount: 2, - threadCount: 3, - }), - ), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), - getProjectShellById: () => Effect.succeed(Option.none()), - getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), - getThreadCheckpointContext: () => Effect.succeed(Option.none()), - getFullThreadDiffContext: () => Effect.succeed(Option.none()), - getThreadRuntimeContext: () => Effect.die("unused"), - getThreadShellById: () => Effect.succeed(Option.none()), - getThreadDetailById: () => Effect.succeed(Option.none()), - getThreadDetailSnapshot: () => Effect.succeed(Option.none()), - searchThreads: () => Effect.succeed({ matches: [] }), - }), - Effect.provideService(AnalyticsService.AnalyticsService, { - record: () => Effect.void, - flush: Effect.void, - }), - ); - - // The heartbeat is forked, so the caller is already back here while - // getCounts is still parked. Awaiting countsStarted proves the forked - // work really ran; releaseCounts staying incomplete proves the caller - // never waited for it. - yield* Deferred.await(countsStarted); - assert.equal(yield* Deferred.isDone(releaseCounts), false); - }), - ), -); - it.effect("resolveWelcomeBase derives cwd and project name from server config", () => Effect.gen(function* () { const welcome = yield* ServerRuntimeStartup.resolveWelcomeBase.pipe( diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 7a5e7b12b865..ea3670f08c9f 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -168,14 +168,6 @@ export const recordStartupHeartbeat = Effect.gen(function* () { }); }); -export const launchStartupHeartbeat = recordStartupHeartbeat.pipe( - Effect.annotateSpans({ "startup.phase": "heartbeat.record" }), - Effect.withSpan("server.startup.heartbeat.record"), - Effect.ignoreCause({ log: true }), - Effect.forkScoped, - Effect.asVoid, -); - const getAutoBootstrapThreadModelSelection = (): ModelSelection => ({ instanceId: ProviderInstanceId.make("codex"), model: DEFAULT_MODEL, From 86f079964b9c3d2a087907d2ef8273c3dbcb960b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:09 -0700 Subject: [PATCH 077/320] refactor(shared): keep search ranking comparator private (#10031) --- packages/shared/src/searchRanking.test.ts | 2 -- packages/shared/src/searchRanking.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/shared/src/searchRanking.test.ts b/packages/shared/src/searchRanking.test.ts index 7e2ccce6e063..8ddf02ec8498 100644 --- a/packages/shared/src/searchRanking.test.ts +++ b/packages/shared/src/searchRanking.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; import { - compareRankedSearchResults, insertRankedSearchResult, normalizeSearchQuery, scoreQueryMatch, @@ -90,6 +89,5 @@ describe("insertRankedSearchResult", () => { insertRankedSearchResult(ranked, { item: "c", score: 30, tieBreaker: "c" }, 2); expect(ranked.map((entry) => entry.item)).toEqual(["a", "b"]); - expect(compareRankedSearchResults(ranked[0]!, ranked[1]!)).toBeLessThan(0); }); }); diff --git a/packages/shared/src/searchRanking.ts b/packages/shared/src/searchRanking.ts index b2fb2e223d3b..c8ec69e39703 100644 --- a/packages/shared/src/searchRanking.ts +++ b/packages/shared/src/searchRanking.ts @@ -135,7 +135,7 @@ export function scoreQueryMatch(input: { return null; } -export function compareRankedSearchResults( +function compareRankedSearchResults( left: RankedSearchResult, right: RankedSearchResult, ): number { From 68aa7aa8305e9dfc0d98543f607d88bd37fb66e2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:46 -0700 Subject: [PATCH 078/320] refactor(server): keep telemetry identity errors private (#10032) --- apps/server/src/telemetry/Identify.test.ts | 20 -------------------- apps/server/src/telemetry/Identify.ts | 4 ++-- 2 files changed, 2 insertions(+), 22 deletions(-) diff --git a/apps/server/src/telemetry/Identify.test.ts b/apps/server/src/telemetry/Identify.test.ts index ab151821789a..92d3223267d7 100644 --- a/apps/server/src/telemetry/Identify.test.ts +++ b/apps/server/src/telemetry/Identify.test.ts @@ -33,26 +33,6 @@ const findIdentityLog = ( errorTag: string, ) => logs.find((log) => log.annotations.source === source && log.annotations.errorTag === errorTag); -it("preserves exact telemetry identity causes without deriving messages from them", () => { - const decodeCause = new Error("private nested decode details"); - const decodeError = new Identify.TelemetryIdentityDecodeError({ - source: "codex", - filePath: "/tmp/auth.json", - cause: decodeCause, - }); - const readCause = new Error("private nested read details"); - const readError = new Identify.TelemetryIdentityReadError({ - source: "anonymous", - filePath: "/tmp/anonymous-id", - cause: readCause, - }); - - assert.strictEqual(decodeError.cause, decodeCause); - assert.strictEqual(readError.cause, readCause); - assert.notInclude(decodeError.message, decodeCause.message); - assert.notInclude(readError.message, readCause.message); -}); - it.layer(NodeServices.layer)("telemetry identity", (it) => { it.effect("uses the persisted anonymous id when provider identities are absent", () => Effect.gen(function* () { diff --git a/apps/server/src/telemetry/Identify.ts b/apps/server/src/telemetry/Identify.ts index b6c3d0066dff..15d3bf13f782 100644 --- a/apps/server/src/telemetry/Identify.ts +++ b/apps/server/src/telemetry/Identify.ts @@ -23,7 +23,7 @@ const ClaudeJsonSchema = Schema.Struct({ export const TelemetryIdentitySource = Schema.Literals(["codex", "claude", "anonymous"]); export type TelemetryIdentitySource = typeof TelemetryIdentitySource.Type; -export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( "TelemetryIdentityReadError", { source: TelemetryIdentitySource, @@ -36,7 +36,7 @@ export class TelemetryIdentityReadError extends Schema.TaggedErrorClass()( +class TelemetryIdentityDecodeError extends Schema.TaggedErrorClass()( "TelemetryIdentityDecodeError", { source: Schema.Literals(["codex", "claude"]), From 83a2897ed6c400b3679a37920e964d829094bd43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:50 -0700 Subject: [PATCH 079/320] refactor(mobile): test composer persistence through the live decoder (#10033) --- .../src/state/use-composer-drafts.test.ts | 17 ++++++++--------- apps/mobile/src/state/use-composer-drafts.ts | 4 ---- 2 files changed, 8 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index e355e0e6dd7f..a5e85227e271 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -159,7 +159,6 @@ import { copyComposerDraftContentIfEmpty, copyComposerDraftContentState, decodePersistedComposerState, - decodePersistedComposerDrafts, ensureComposerDraftsLoaded, type ComposerDraft, flushComposerDrafts, @@ -252,12 +251,12 @@ describe("mobile composer drafts", () => { }; expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": { text: "Review this file", attachments: [file] }, }); @@ -987,7 +986,7 @@ describe("mobile composer drafts", () => { it("rejects persisted images without image bytes or a file URI", () => { expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { @@ -1010,7 +1009,7 @@ describe("mobile composer drafts", () => { it("hydrates selector state even when the message content is empty", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "new-task:environment-1:project-1": { @@ -1030,7 +1029,7 @@ describe("mobile composer drafts", () => { }, }, }, - }), + }).drafts, ).toEqual({ "new-task:environment-1:project-1": { text: "", @@ -1053,18 +1052,18 @@ describe("mobile composer drafts", () => { it("keeps legacy content-only drafts and rejects invalid selector state", () => { expect( - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": DRAFT, }, - }), + }).drafts, ).toEqual({ "environment-1:thread-1": DRAFT, }); expect(() => - decodePersistedComposerDrafts({ + decodePersistedComposerState({ schemaVersion: 1, drafts: { "environment-1:thread-1": { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 065331d67064..6b463c2d2624 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -238,10 +238,6 @@ export function decodePersistedComposerState(value: unknown): { }; } -export function decodePersistedComposerDrafts(value: unknown): Record { - return decodePersistedComposerState(value).drafts; -} - async function getComposerDraftsFile() { const { Directory, File, Paths } = await import("expo-file-system"); const directory = new Directory(Paths.document, COMPOSER_DRAFTS_DIRECTORY); From 5cb696595cd66bb00e03fa5f91d9cd4273949f5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:20:54 -0700 Subject: [PATCH 080/320] refactor(web): remove unused sidebar selectors (#10034) --- apps/web/src/components/Sidebar.logic.test.ts | 144 ------------------ apps/web/src/components/Sidebar.logic.ts | 82 ---------- 2 files changed, 226 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index bf3d37d0edc2..cd56835ffd8b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -9,11 +9,9 @@ import { createThreadJumpHintVisibilityController, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, - getVisibleSidebarThreadIds, resolveAdjacentThreadId, reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, - getVisibleThreadsForProject, getProjectSortTimestamp, hasUnseenCompletion, isContextMenuPointerDown, @@ -27,7 +25,6 @@ import { resolveWorkingStartedAt, searchSidebarThreadsByTitle, formatWorkingDurationLabel, - shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, @@ -84,56 +81,6 @@ describe("animatePinnedLayoutChanges", () => { }); }); -describe("shouldNavigateAfterProjectRemoval", () => { - const projectThreads = [{ environmentId: "environment-local", id: "thread-1" }]; - - it("navigates away from a draft route owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-1" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(true); - }); - - it("does not navigate away from a different draft route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { kind: "draft", draftId: "draft-2" as never }, - projectThreads, - projectDraftId: "draft-1", - }), - ).toBe(false); - }); - - it("navigates away from a server thread owned by the removed project", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: { - kind: "server", - threadRef: { - environmentId: EnvironmentId.make("environment-local"), - threadId: ThreadId.make("thread-1"), - }, - }, - projectThreads, - projectDraftId: null, - }), - ).toBe(true); - }); - - it("does not navigate from an unrelated route", () => { - expect( - shouldNavigateAfterProjectRemoval({ - routeTarget: null, - projectThreads, - projectDraftId: null, - }), - ).toBe(false); - }); -}); - describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; @@ -644,46 +591,6 @@ describe("resolveAdjacentThreadId", () => { }); }); -describe("getVisibleSidebarThreadIds", () => { - it("returns only the rendered visible thread order across projects", () => { - expect( - getVisibleSidebarThreadIds([ - { - renderedThreadIds: [ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ], - }, - { - renderedThreadIds: [ThreadId.make("thread-8"), ThreadId.make("thread-6")], - }, - ]), - ).toEqual([ - ThreadId.make("thread-12"), - ThreadId.make("thread-11"), - ThreadId.make("thread-10"), - ThreadId.make("thread-8"), - ThreadId.make("thread-6"), - ]); - }); - - it("skips threads from collapsed projects whose thread panels are not shown", () => { - expect( - getVisibleSidebarThreadIds([ - { - shouldShowThreadPanel: false, - renderedThreadIds: [ThreadId.make("thread-hidden-2"), ThreadId.make("thread-hidden-1")], - }, - { - shouldShowThreadPanel: true, - renderedThreadIds: [ThreadId.make("thread-12"), ThreadId.make("thread-11")], - }, - ]), - ).toEqual([ThreadId.make("thread-12"), ThreadId.make("thread-11")]); - }); -}); - describe("isContextMenuPointerDown", () => { it("treats secondary-button presses as context menu gestures on all platforms", () => { expect( @@ -1362,57 +1269,6 @@ describe("resolveProjectStatusIndicator", () => { }); }); -describe("getVisibleThreadsForProject", () => { - it("includes the active thread even when it falls below the folded preview", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - title: `Thread ${index + 1}`, - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: false, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual([ - ThreadId.make("thread-1"), - ThreadId.make("thread-2"), - ThreadId.make("thread-3"), - ThreadId.make("thread-4"), - ThreadId.make("thread-5"), - ThreadId.make("thread-6"), - ThreadId.make("thread-8"), - ]); - expect(result.hiddenThreads.map((thread) => thread.id)).toEqual([ThreadId.make("thread-7")]); - }); - - it("returns all threads when the list is expanded", () => { - const threads = Array.from({ length: 8 }, (_, index) => - makeThread({ - id: ThreadId.make(`thread-${index + 1}`), - }), - ); - - const result = getVisibleThreadsForProject({ - threads, - activeThreadId: ThreadId.make("thread-8"), - isThreadListExpanded: true, - previewLimit: 6, - }); - - expect(result.hasHiddenThreads).toBe(true); - expect(result.visibleThreads.map((thread) => thread.id)).toEqual( - threads.map((thread) => thread.id), - ); - expect(result.hiddenThreads).toEqual([]); - }); -}); - function makeProject(overrides: Partial = {}): Project { const { defaultModelSelection, ...rest } = overrides; return { diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 8e88aea37f4c..92cdf72c51fe 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -12,7 +12,6 @@ import { type ThreadSortInput, } from "../lib/threadSort"; import type { SidebarThreadSummary, Thread } from "../types"; -import type { ThreadRouteTarget } from "../threadRoutes"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; @@ -407,17 +406,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getVisibleSidebarThreadIds( - renderedProjects: readonly { - shouldShowThreadPanel?: boolean; - renderedThreadIds: readonly TThreadId[]; - }[], -): TThreadId[] { - return renderedProjects.flatMap((renderedProject) => - renderedProject.shouldShowThreadPanel === false ? [] : renderedProject.renderedThreadIds, - ); -} - export function getSidebarThreadIdsToPrewarm( visibleThreadIds: readonly TThreadId[], limit = SIDEBAR_THREAD_PREWARM_LIMIT, @@ -452,28 +440,6 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } -export function shouldNavigateAfterProjectRemoval(input: { - routeTarget: ThreadRouteTarget | null; - projectThreads: readonly { - environmentId: string; - id: string; - }[]; - projectDraftId: string | null; -}): boolean { - const { projectDraftId, projectThreads, routeTarget } = input; - if (routeTarget?.kind === "draft") { - return projectDraftId === routeTarget.draftId; - } - if (routeTarget?.kind !== "server") { - return false; - } - return projectThreads.some( - (thread) => - thread.environmentId === routeTarget.threadRef.environmentId && - thread.id === routeTarget.threadRef.threadId, - ); -} - export function isContextMenuPointerDown(input: { button: number; ctrlKey: boolean; @@ -838,54 +804,6 @@ export function resolveProjectStatusIndicator( return highestPriorityStatus; } -export function getVisibleThreadsForProject>(input: { - threads: readonly T[]; - activeThreadId: T["id"] | undefined; - isThreadListExpanded: boolean; - previewLimit: number; -}): { - hasHiddenThreads: boolean; - visibleThreads: T[]; - hiddenThreads: T[]; -} { - const { activeThreadId, isThreadListExpanded, previewLimit, threads } = input; - const hasHiddenThreads = threads.length > previewLimit; - - if (!hasHiddenThreads || isThreadListExpanded) { - return { - hasHiddenThreads, - hiddenThreads: [], - visibleThreads: [...threads], - }; - } - - const previewThreads = threads.slice(0, previewLimit); - if (!activeThreadId || previewThreads.some((thread) => thread.id === activeThreadId)) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const activeThread = threads.find((thread) => thread.id === activeThreadId); - if (!activeThread) { - return { - hasHiddenThreads: true, - hiddenThreads: threads.slice(previewLimit), - visibleThreads: previewThreads, - }; - } - - const visibleThreadIds = new Set([...previewThreads, activeThread].map((thread) => thread.id)); - - return { - hasHiddenThreads: true, - hiddenThreads: threads.filter((thread) => !visibleThreadIds.has(thread.id)), - visibleThreads: threads.filter((thread) => visibleThreadIds.has(thread.id)), - }; -} - export function getFallbackThreadIdAfterDelete< T extends Pick & ThreadSortInput, >(input: { From ad3721eb5bec31066d162916f700107aab5e125b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:22:08 -0700 Subject: [PATCH 081/320] refactor(server): keep Cursor fallback models private (#10038) --- .../server/src/provider/Layers/CursorProvider.test.ts | 11 ----------- apps/server/src/provider/Layers/CursorProvider.ts | 2 +- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/apps/server/src/provider/Layers/CursorProvider.test.ts b/apps/server/src/provider/Layers/CursorProvider.test.ts index 78edd8acbd45..4fa382c788d5 100644 --- a/apps/server/src/provider/Layers/CursorProvider.test.ts +++ b/apps/server/src/provider/Layers/CursorProvider.test.ts @@ -16,7 +16,6 @@ import { buildCursorCapabilitiesFromConfigOptions, checkCursorProviderStatus, discoverCursorModelsViaAcp, - getCursorFallbackModels, getCursorParameterizedModelPickerUnsupportedMessage, parseCursorAboutOutput, parseCursorCliConfigChannel, @@ -475,16 +474,6 @@ describe("Cursor skills", () => { }); }); -describe("getCursorFallbackModels", () => { - it("does not publish any built-in cursor models before ACP discovery", () => { - expect( - getCursorFallbackModels({ - customModels: ["internal/cursor-model"], - }).map((model) => model.slug), - ).toEqual(["internal/cursor-model"]); - }); -}); - describe("buildCursorProviderSnapshot", () => { it("downgrades ready status to warning when ACP model discovery times out", () => { expect( diff --git a/apps/server/src/provider/Layers/CursorProvider.ts b/apps/server/src/provider/Layers/CursorProvider.ts index e6c7853844e0..48fbfe27c482 100644 --- a/apps/server/src/provider/Layers/CursorProvider.ts +++ b/apps/server/src/provider/Layers/CursorProvider.ts @@ -572,7 +572,7 @@ export const discoverCursorModelsViaAcp = ( environment?: NodeJS.ProcessEnv, ) => discoverCursorModelsViaListAvailableModels(cursorSettings, environment); -export function getCursorFallbackModels( +function getCursorFallbackModels( cursorSettings: Pick, ): ReadonlyArray { return providerModelsFromSettings([], cursorSettings.customModels, EMPTY_CAPABILITIES); From 07fb04dc63694876b531a7bb5492a72b8282009d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:22:57 -0700 Subject: [PATCH 082/320] refactor(web): remove unused xterm link range helpers (#10040) --- apps/web/src/terminal-links.test.ts | 42 -------------------------- apps/web/src/terminal-links.ts | 47 ----------------------------- 2 files changed, 89 deletions(-) diff --git a/apps/web/src/terminal-links.test.ts b/apps/web/src/terminal-links.test.ts index 34c6c9830a0a..3c466378ba8b 100644 --- a/apps/web/src/terminal-links.test.ts +++ b/apps/web/src/terminal-links.test.ts @@ -6,8 +6,6 @@ import { isTerminalLinkActivation, isTerminalUrl, resolvePathLinkTarget, - resolveWrappedTerminalLinkRange, - wrappedTerminalLinkRangeIntersectsBufferLine, type TerminalBufferLineLike, } from "./terminal-links"; @@ -154,46 +152,6 @@ describe("collectWrappedTerminalLinkLine", () => { }); }); -describe("resolveWrappedTerminalLinkRange", () => { - it("maps wrapped URL matches back to the correct buffer rows", () => { - const prefix = "see "; - const firstSegment = `${prefix}https://example.com/a`; - const secondSegment = "/bc?x=1"; - const lines = [ - createBufferLine("prompt> "), - createBufferLine(firstSegment), - createBufferLine(secondSegment, true), - ]; - const wrappedLine = collectWrappedTerminalLinkLine(2, (index) => lines[index]); - - expect(wrappedLine).not.toBeNull(); - if (!wrappedLine) { - throw new Error("Expected wrapped terminal line to be present."); - } - - const [match] = extractTerminalLinks(wrappedLine.text); - expect(match).toEqual({ - kind: "url", - text: "https://example.com/a/bc?x=1", - start: prefix.length, - end: firstSegment.length + secondSegment.length, - }); - if (!match) { - throw new Error("Expected wrapped URL match to be present."); - } - - const range = resolveWrappedTerminalLinkRange(wrappedLine, match); - - expect(range).toEqual({ - start: { x: prefix.length + 1, y: 2 }, - end: { x: secondSegment.length, y: 3 }, - }); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 2)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 3)).toBe(true); - expect(wrappedTerminalLinkRangeIntersectsBufferLine(range, 4)).toBe(false); - }); -}); - describe("resolvePathLinkTarget", () => { it("resolves relative paths against cwd", () => { expect( diff --git a/apps/web/src/terminal-links.ts b/apps/web/src/terminal-links.ts index 204d0a742a05..59e2082a7359 100644 --- a/apps/web/src/terminal-links.ts +++ b/apps/web/src/terminal-links.ts @@ -14,16 +14,6 @@ export interface TerminalLinkMatch { end: number; } -export interface TerminalLinkBufferPosition { - x: number; - y: number; -} - -export interface TerminalLinkBufferRange { - start: TerminalLinkBufferPosition; - end: TerminalLinkBufferPosition; -} - export interface TerminalBufferLineLike { readonly isWrapped?: boolean; translateToString(trimRight?: boolean): string; @@ -199,43 +189,6 @@ export function collectWrappedTerminalLinkLine( }; } -function resolveCharacterPosition( - segments: ReadonlyArray, - characterIndex: number, -): TerminalLinkBufferPosition { - for (const segment of segments) { - if (characterIndex < segment.endIndex) { - return { - x: characterIndex - segment.startIndex + 1, - y: segment.bufferLineNumber, - }; - } - } - - const lastSegment = segments[segments.length - 1]; - return { - x: Math.max(lastSegment?.text.length ?? 0, 1), - y: lastSegment?.bufferLineNumber ?? 1, - }; -} - -export function resolveWrappedTerminalLinkRange( - wrappedLine: WrappedTerminalLinkLine, - match: Pick, -): TerminalLinkBufferRange { - return { - start: resolveCharacterPosition(wrappedLine.segments, match.start), - end: resolveCharacterPosition(wrappedLine.segments, match.end - 1), - }; -} - -export function wrappedTerminalLinkRangeIntersectsBufferLine( - range: TerminalLinkBufferRange, - bufferLineNumber: number, -): boolean { - return range.start.y <= bufferLineNumber && bufferLineNumber <= range.end.y; -} - export function isTerminalLinkActivation( event: Pick, platform = typeof navigator === "undefined" ? "" : navigator.platform, From c059d09b9b585d0c9562527e338e2f95cd2220f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:23:45 -0700 Subject: [PATCH 083/320] refactor(mobile): remove obsolete review list builder (#10039) --- .../src/features/review/reviewModel.test.ts | 81 ------------- .../mobile/src/features/review/reviewModel.ts | 110 ------------------ 2 files changed, 191 deletions(-) diff --git a/apps/mobile/src/features/review/reviewModel.test.ts b/apps/mobile/src/features/review/reviewModel.test.ts index 3390afd9ff27..ee568085f7a2 100644 --- a/apps/mobile/src/features/review/reviewModel.test.ts +++ b/apps/mobile/src/features/review/reviewModel.test.ts @@ -8,7 +8,6 @@ import { } from "@t3tools/contracts"; import { - buildReviewListItems, buildReviewParsedDiff, buildReviewSectionItems, getDefaultReviewSectionId, @@ -271,84 +270,4 @@ describe("buildReviewParsedDiff", () => { actionLabel: "Load diff", }); }); - - it("flattens expanded file rows into virtualized review items", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/a.ts", - rows: [ - { - kind: "hunk", - id: "hunk-1", - header: "@@ -1,1 +1,2 @@", - context: null, - }, - { - kind: "line", - id: "line-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, - }, - ], - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "hunk", - fileId: file.id, - file, - row: file.rows[0], - }), - expect.objectContaining({ - kind: "line", - fileId: file.id, - file, - row: file.rows[1], - lineIndex: 0, - }), - ]); - }); - - it("keeps large diffs collapsed into a placeholder item until revealed", () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/big.ts", - rows: Array.from({ length: 401 }, (_, index) => ({ - kind: "line" as const, - id: `line-${index}`, - change: "add" as const, - oldLineNumber: null, - newLineNumber: index + 1, - content: `const line${index} = ${index};`, - additionTokenIndex: index, - deletionTokenIndex: null, - comparison: null, - })), - }); - - const items = buildReviewListItems({ - files: [file], - expandedFileIds: [file.id], - revealedLargeFileIds: [], - }); - - expect(items).toEqual([ - expect.objectContaining({ kind: "file-header", fileId: file.id, expanded: true }), - expect.objectContaining({ - kind: "file-suppressed", - fileId: file.id, - actionLabel: "Load diff", - }), - ]); - }); }); diff --git a/apps/mobile/src/features/review/reviewModel.ts b/apps/mobile/src/features/review/reviewModel.ts index 9459d41872d1..202157b837cc 100644 --- a/apps/mobile/src/features/review/reviewModel.ts +++ b/apps/mobile/src/features/review/reviewModel.ts @@ -58,45 +58,6 @@ export interface ReviewRenderableFile { readonly rows: ReadonlyArray; } -export interface ReviewFileHeaderListItem { - readonly kind: "file-header"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly expanded: boolean; -} - -export interface ReviewFileSuppressedListItem { - readonly kind: "file-suppressed"; - readonly id: string; - readonly fileId: string; - readonly message: string; - readonly actionLabel: string | null; -} - -export interface ReviewHunkListItem { - readonly kind: "hunk"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableHunkRow; -} - -export interface ReviewLineListItem { - readonly kind: "line"; - readonly id: string; - readonly fileId: string; - readonly file: ReviewRenderableFile; - readonly row: ReviewRenderableLineRow; - readonly lineIndex: number; -} - -export type ReviewListItem = - | ReviewFileHeaderListItem - | ReviewFileSuppressedListItem - | ReviewHunkListItem - | ReviewLineListItem; - export type ReviewFilePreviewState = | { readonly kind: "render"; @@ -316,77 +277,6 @@ export function getReviewFilePreviewState(file: ReviewRenderableFile): ReviewFil return { kind: "render" }; } -// The flattened review list item model is inspired by pierre/diffs' iterator-first -// virtualization architecture, adapted here for React Native virtualization. -// Original project: https://github.com/pingdotgg/pierre/tree/main/packages/diffs -// Reference files: -// - src/utils/iterateOverDiff.ts -// - src/components/VirtualizedFileDiff.ts -export function buildReviewListItems(input: { - readonly files: ReadonlyArray; - readonly expandedFileIds: ReadonlyArray; - readonly revealedLargeFileIds: ReadonlyArray; -}): ReadonlyArray { - const expandedFileIds = new Set(input.expandedFileIds); - const revealedLargeFileIds = new Set(input.revealedLargeFileIds); - const items: ReviewListItem[] = []; - - input.files.forEach((file) => { - const expanded = expandedFileIds.has(file.id); - items.push({ - kind: "file-header", - id: `${file.id}:header`, - fileId: file.id, - file, - expanded, - }); - - if (!expanded) { - return; - } - - const previewState = getReviewFilePreviewState(file); - if (previewState.kind === "suppressed") { - if (previewState.reason !== "large" || !revealedLargeFileIds.has(file.id)) { - items.push({ - kind: "file-suppressed", - id: `${file.id}:suppressed`, - fileId: file.id, - message: previewState.message, - actionLabel: previewState.actionLabel, - }); - return; - } - } - - let lineIndex = 0; - file.rows.forEach((row, rowIndex) => { - if (row.kind === "hunk") { - items.push({ - kind: "hunk", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - }); - return; - } - - items.push({ - kind: "line", - id: `${file.id}:row:${rowIndex}:${row.id}`, - fileId: file.id, - file, - row, - lineIndex, - }); - lineIndex += 1; - }); - }); - - return items; -} - function fallbackHunkHeader(hunk: FileDiffMetadata["hunks"][number]): string { return `@@ -${hunk.deletionStart},${hunk.deletionCount} +${hunk.additionStart},${hunk.additionCount} @@`; } From a21c0d724684fe3e1775014fa0dad14e483c8315 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:07 -0700 Subject: [PATCH 084/320] test(server): remove duplicate VCS error constructor checks (#10042) --- apps/server/src/vcs/VcsProjectConfig.test.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/apps/server/src/vcs/VcsProjectConfig.test.ts b/apps/server/src/vcs/VcsProjectConfig.test.ts index 04f7fcffcda0..88f48e9e8afa 100644 --- a/apps/server/src/vcs/VcsProjectConfig.test.ts +++ b/apps/server/src/vcs/VcsProjectConfig.test.ts @@ -14,22 +14,6 @@ const TestLayer = VcsProjectConfig.layer.pipe( ); describe("VcsProjectConfig", () => { - it("keeps operation context and the original cause on config errors", () => { - const cause = new Error("permission denied"); - const error = new VcsProjectConfig.VcsProjectConfigError({ - operation: "read", - cwd: "/repo/packages/app", - configPath: "/repo/.t3code/vcs.json", - cause, - }); - - assert.equal(error.operation, "read"); - assert.equal(error.cwd, "/repo/packages/app"); - assert.equal(error.configPath, "/repo/.t3code/vcs.json"); - assert.strictEqual(error.cause, cause); - assert.equal(error.message, "Failed to read VCS project config at /repo/.t3code/vcs.json."); - }); - it.layer(TestLayer)("uses an explicit requested VCS kind before config", (it) => { it.effect("returns the requested kind", () => Effect.gen(function* () { From 5e828bf3de1c86d85afdd91a02cc4ca1a38d4aef Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:11 -0700 Subject: [PATCH 085/320] refactor(mobile): keep appearance calculations private (#10043) --- .../src/lib/appearancePreferences.test.ts | 19 ++++++------------- apps/mobile/src/lib/appearancePreferences.ts | 8 ++++---- 2 files changed, 10 insertions(+), 17 deletions(-) diff --git a/apps/mobile/src/lib/appearancePreferences.test.ts b/apps/mobile/src/lib/appearancePreferences.test.ts index af09637b4e9f..417a66138d95 100644 --- a/apps/mobile/src/lib/appearancePreferences.test.ts +++ b/apps/mobile/src/lib/appearancePreferences.test.ts @@ -2,11 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { DEFAULT_BASE_FONT_SIZE, - deriveCodeFontSize, - deriveTerminalFontSize, normalizeBaseFontSize, - normalizeCodeFontSize, - normalizeCodeWordBreak, resolveAppearance, resolveAppearancePreferences, resolveMarkdownFontSizes, @@ -48,10 +44,8 @@ describe("appearancePreferences", () => { expect(appearance.isCodeFontSizeCustom).toBe(false); const scaled = resolveAppearance(resolveAppearancePreferences({ baseFontSize: 22 })); - expect(scaled.terminalFontSize).toBe(deriveTerminalFontSize(22)); - expect(scaled.codeFontSize).toBe(deriveCodeFontSize(22)); - expect(scaled.terminalFontSize).toBeGreaterThan(10); - expect(scaled.codeFontSize).toBeGreaterThan(11); + expect(scaled.terminalFontSize).toBe(14); + expect(scaled.codeFontSize).toBe(17); }); it("applies explicit overrides over derived values", () => { @@ -67,8 +61,8 @@ describe("appearancePreferences", () => { it("clamps base and code font sizes", () => { expect(normalizeBaseFontSize(4)).toBe(11); expect(normalizeBaseFontSize(30)).toBe(22); - expect(normalizeCodeFontSize(4)).toBe(8); - expect(normalizeCodeFontSize(30)).toBe(18); + expect(resolveAppearancePreferences({ codeFontSize: 4 }).codeFontSize).toBe(8); + expect(resolveAppearancePreferences({ codeFontSize: 30 }).codeFontSize).toBe(18); }); it("steps terminal font size within bounds", () => { @@ -92,9 +86,8 @@ describe("appearancePreferences", () => { }); }); - it("defaults code word break to false", () => { - expect(normalizeCodeWordBreak(undefined)).toBe(false); - expect(normalizeCodeWordBreak(true)).toBe(true); + it("keeps explicit code word break enabled", () => { + expect(resolveAppearancePreferences({ codeWordBreak: true }).codeWordBreak).toBe(true); }); it("returns the authored text scale at the 16pt default", () => { diff --git a/apps/mobile/src/lib/appearancePreferences.ts b/apps/mobile/src/lib/appearancePreferences.ts index b81c50056543..2ce6a8b5a367 100644 --- a/apps/mobile/src/lib/appearancePreferences.ts +++ b/apps/mobile/src/lib/appearancePreferences.ts @@ -75,7 +75,7 @@ export function normalizeBaseFontSize(value: number | null | undefined): number return Math.min(MAX_BASE_FONT_SIZE, Math.max(MIN_BASE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeFontSize(value: number | null | undefined): number { +function normalizeCodeFontSize(value: number | null | undefined): number { if (typeof value !== "number" || !Number.isFinite(value)) { return DEFAULT_CODE_FONT_SIZE; } @@ -83,18 +83,18 @@ export function normalizeCodeFontSize(value: number | null | undefined): number return Math.min(MAX_CODE_FONT_SIZE, Math.max(MIN_CODE_FONT_SIZE, Math.round(value))); } -export function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { +function normalizeCodeWordBreak(value: boolean | null | undefined): boolean { return value === true; } /** Terminal size derived from base: 10.5pt at base 16, snapped to 0.5pt steps. */ -export function deriveTerminalFontSize(baseFontSize: number): number { +function deriveTerminalFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeTerminalFontSize(Math.round(DEFAULT_TERMINAL_FONT_SIZE * scale * 2) / 2); } /** Code/diff size derived from base: 12pt at base 16. */ -export function deriveCodeFontSize(baseFontSize: number): number { +function deriveCodeFontSize(baseFontSize: number): number { const scale = normalizeBaseFontSize(baseFontSize) / DEFAULT_BASE_FONT_SIZE; return normalizeCodeFontSize(Math.round(DEFAULT_CODE_FONT_SIZE * scale)); } From 5a4287cd63aefef93c0fd15fe79f63a2f2657211 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:25:15 -0700 Subject: [PATCH 086/320] refactor(web): remove unused sidebar menu action (#10044) --- apps/web/src/components/ui/sidebar.test.tsx | 12 ------- apps/web/src/components/ui/sidebar.tsx | 35 +-------------------- 2 files changed, 1 insertion(+), 46 deletions(-) diff --git a/apps/web/src/components/ui/sidebar.test.tsx b/apps/web/src/components/ui/sidebar.test.tsx index e2d29d607e13..784c5e087963 100644 --- a/apps/web/src/components/ui/sidebar.test.tsx +++ b/apps/web/src/components/ui/sidebar.test.tsx @@ -2,7 +2,6 @@ import { renderToStaticMarkup } from "react-dom/server"; import { describe, expect, it } from "vite-plus/test"; import { - SidebarMenuAction, SidebarMenuButton, SidebarMenuSubButton, SidebarProvider, @@ -89,17 +88,6 @@ describe("sidebar interactive cursors", () => { expect(html).not.toContain("cursor-pointer"); }); - it("uses a pointer cursor for menu actions", () => { - const html = renderToStaticMarkup( - - + - , - ); - - expect(html).toContain('data-slot="sidebar-menu-action"'); - expect(html).toContain("cursor-pointer"); - }); - it("uses a pointer cursor for submenu buttons", () => { const html = renderToStaticMarkup( }>Show more, diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index 624798e19f54..22cb4808fadd 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -800,7 +800,7 @@ function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) { } const sidebarMenuButtonVariants = cva( - "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pe-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", + "peer/menu-button flex w-full cursor-pointer items-center gap-[var(--sidebar-control-gap)] overflow-hidden text-left outline-hidden ring-ring transition-[width,height,padding] hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 active:bg-sidebar-row-active active:text-sidebar-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-row-selected data-[active=true]:font-medium data-[active=true]:text-sidebar-foreground data-[state=open]:hover:bg-sidebar-row-hover data-[state=open]:hover:text-sidebar-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-[var(--sidebar-content-inset)]! [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-[var(--sidebar-icon-color)] hover:[&>svg]:text-sidebar-foreground active:[&>svg]:text-sidebar-foreground data-[active=true]:[&>svg]:text-sidebar-foreground", { defaultVariants: { size: "default", @@ -875,38 +875,6 @@ function SidebarMenuButton({ ); } -function SidebarMenuAction({ - className, - showOnHover = false, - render, - ...props -}: useRender.ComponentProps<"button"> & { - showOnHover?: boolean; -}) { - const defaultProps = { - className: cn( - "absolute top-1.5 right-1 flex aspect-square w-5 cursor-pointer items-center justify-center rounded-lg p-0 text-sidebar-foreground outline-hidden ring-ring transition-transform hover:bg-sidebar-row-hover hover:text-sidebar-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-foreground [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0", - // Increases the hit area of the button on mobile. - "after:-inset-2 after:absolute md:after:hidden", - "peer-data-[size=sm]/menu-button:top-1", - "peer-data-[size=default]/menu-button:top-1.5", - "peer-data-[size=lg]/menu-button:top-2.5", - "group-data-[collapsible=icon]:hidden", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 peer-data-[active=true]/menu-button:text-sidebar-foreground md:opacity-0", - className, - ), - "data-sidebar": "menu-action", - "data-slot": "sidebar-menu-action", - }; - - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">(defaultProps, props), - render, - }); -} - function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { return (
Date: Sat, 5 Sep 2026 01:29:35 -0700 Subject: [PATCH 087/320] refactor(web): test live Ghostty link resolution directly (#10041) --- apps/web/src/terminal/ghostty/surface.test.ts | 30 +++++++++---------- apps/web/src/terminal/ghostty/surface.ts | 12 -------- 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/apps/web/src/terminal/ghostty/surface.test.ts b/apps/web/src/terminal/ghostty/surface.test.ts index e9933cdb4b73..ee3240b41b08 100644 --- a/apps/web/src/terminal/ghostty/surface.test.ts +++ b/apps/web/src/terminal/ghostty/surface.test.ts @@ -23,8 +23,6 @@ import { terminalGridCellAt, terminalScrollbarGeometry, terminalScrollbarOffsetAtPointer, - terminalLinkAtColumn, - terminalLinkAtPosition, terminalLinkAtPositionWithRange, terminalContentOriginY, terminalFontFamily, @@ -433,7 +431,7 @@ describe("shouldBlinkTerminalCursor", () => { }); }); -describe("terminalLinkAtColumn", () => { +describe("terminalLinkAtPositionWithRange", () => { it("maps terminal cells to UTF-16 offsets after a wide emoji", () => { const cells = [ cell("🙂"), @@ -450,9 +448,11 @@ describe("terminalLinkAtColumn", () => { wrapsToNext: false, }; - expect(terminalLinkAtColumn(row, 2)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, cells.length - 1)).toBe("https://t3.codes"); - expect(terminalLinkAtColumn(row, 0)).toBeNull(); + expect(terminalLinkAtPositionWithRange([row], 0, 2)?.text).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([row], 0, cells.length - 1)?.text).toBe( + "https://t3.codes", + ); + expect(terminalLinkAtPositionWithRange([row], 0, 0)).toBeNull(); expect(terminalLinkAtPositionWithRange([row], 0, 8)?.range).toEqual({ start: { x: 2, y: 0 }, end: { x: cells.length - 1, y: 0 }, @@ -473,10 +473,10 @@ describe("terminalLinkAtColumn", () => { row("C:\\repo\\file.ts", false), ]; - expect(terminalLinkAtPosition(rows, 0, 8)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 1, 4)).toBe("https://example.com/reference"); - expect(terminalLinkAtPosition(rows, 2, 2)).toBe("~/project/file"); - expect(terminalLinkAtPosition(rows, 3, 4)).toBe("C:\\repo\\file.ts"); + expect(terminalLinkAtPositionWithRange(rows, 0, 8)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 1, 4)?.text).toBe("https://example.com/reference"); + expect(terminalLinkAtPositionWithRange(rows, 2, 2)?.text).toBe("~/project/file"); + expect(terminalLinkAtPositionWithRange(rows, 3, 4)?.text).toBe("C:\\repo\\file.ts"); expect(terminalLinkAtPositionWithRange(rows, 1, 4)).toEqual({ text: "https://example.com/reference", range: { @@ -495,13 +495,13 @@ describe("terminalLinkAtColumn", () => { }); // The head of the wrapped line scrolled above the viewport. const headCut = [row("ple.com/missing", true), row("head", true)]; - expect(terminalLinkAtPosition(headCut, 0, 4)).toBeNull(); + expect(terminalLinkAtPositionWithRange(headCut, 0, 4)).toBeNull(); // The bottom row soft-wraps on below the viewport. const tailCut = [row("https://t3.codes", false, true)]; - expect(terminalLinkAtPosition(tailCut, 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange(tailCut, 0, 8)).toBeNull(); // A partial bottom row is provably complete and still resolves. const complete = [row("https://t3.codes", false), row("", false)]; - expect(terminalLinkAtPosition(complete, 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange(complete, 0, 8)?.text).toBe("https://t3.codes"); // A wide grapheme earlier in the row must not break truncation detection: // the soft-wrap flag decides, not string-length-versus-cell-count. const wideFull: GhosttyRow = { @@ -514,7 +514,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: true, }; - expect(terminalLinkAtPosition([wideFull], 0, 8)).toBeNull(); + expect(terminalLinkAtPositionWithRange([wideFull], 0, 8)).toBeNull(); // Unwritten trailing cells prove the bottom row is complete. const unwrittenTail: GhosttyRow = { cells: [ @@ -526,7 +526,7 @@ describe("terminalLinkAtColumn", () => { isWrapContinuation: false, wrapsToNext: false, }; - expect(terminalLinkAtPosition([unwrittenTail], 0, 8)).toBe("https://t3.codes"); + expect(terminalLinkAtPositionWithRange([unwrittenTail], 0, 8)?.text).toBe("https://t3.codes"); }); }); diff --git a/apps/web/src/terminal/ghostty/surface.ts b/apps/web/src/terminal/ghostty/surface.ts index 826dcd014053..29aaac6f6abd 100644 --- a/apps/web/src/terminal/ghostty/surface.ts +++ b/apps/web/src/terminal/ghostty/surface.ts @@ -244,14 +244,6 @@ function terminalColumnOffset(row: GhosttySnapshot["rowData"][number], column: n return offset; } -export function terminalLinkAtPosition( - rows: GhosttySnapshot["rowData"], - rowIndex: number, - column: number, -): string | null { - return terminalLinkAtPositionWithRange(rows, rowIndex, column)?.text ?? null; -} - export interface TerminalLinkWithRange { readonly text: string; readonly range: GhosttyCellRange; @@ -325,10 +317,6 @@ export function terminalLinkAtPositionWithRange( return null; } -export function terminalLinkAtColumn(row: GhosttySnapshot["rowData"][number], column: number) { - return terminalLinkAtPosition([row], 0, column); -} - export function isTerminalCopyShortcut( event: Pick, platform = navigator.platform, From df370c31d26afc72ace55263e746ba4386dada25 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:29:40 -0700 Subject: [PATCH 088/320] test(server): exercise Codex prompts through public assembly (#10045) --- .../provider/CodexDeveloperInstructions.ts | 4 +-- .../Layers/CodexSessionRuntime.test.ts | 25 +++++++------------ 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 2bb9647bd414..1c2439a9ad9a 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -22,7 +22,7 @@ Do not switch to global browser skills, Chrome, Node REPL browser automation, st const browserToolInstructions = (browserToolsAvailable: boolean): string => browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : ""; -export const codexPlanModeDeveloperInstructions = ( +const codexPlanModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Plan Mode (Conversational) @@ -155,7 +155,7 @@ If the user stays in Plan mode and asks for revisions after a prior \``; -export const codexDefaultModeDeveloperInstructions = ( +const codexDefaultModeDeveloperInstructions = ( browserToolsAvailable: boolean, ): string => `# Collaboration Mode: Default diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index fd4d66dd497b..0136c3fbf170 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -9,11 +9,7 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { - buildCodexDeveloperInstructions, - codexDefaultModeDeveloperInstructions, - codexPlanModeDeveloperInstructions, -} from "../CodexDeveloperInstructions.ts"; +import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, @@ -459,7 +455,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "high", }); - NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Collaboration Mode: Default/); NodeAssert.match(instructions, /T3 Code/); NodeAssert.match(instructions, /Codex harness/); NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/); @@ -484,7 +480,7 @@ describe("buildCodexDeveloperInstructions", () => { reasoningEffort: "medium", }); - NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true))); + NodeAssert.match(instructions, /^# Plan Mode/); NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/); }); @@ -513,11 +509,11 @@ describe("buildCodexDeveloperInstructions", () => { }); describe("T3 browser developer instructions", () => { + const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; + it("prefers the product-native preview tools in both collaboration modes", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(true), - codexPlanModeDeveloperInstructions(true), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, true); NodeAssert.match(instructions, /t3-code/); NodeAssert.match(instructions, /preview_status/); NodeAssert.match(instructions, /preview_open/); @@ -526,10 +522,8 @@ describe("T3 browser developer instructions", () => { }); it("omits the browser block entirely when the preview tools are not attached", () => { - for (const instructions of [ - codexDefaultModeDeveloperInstructions(false), - codexPlanModeDeveloperInstructions(false), - ]) { + for (const mode of ["default", "plan"] as const) { + const instructions = buildCodexDeveloperInstructions(mode, runtime, false); NodeAssert.doesNotMatch(instructions, /preview_status/); NodeAssert.doesNotMatch(instructions, /preview_open/); NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/); @@ -543,7 +537,6 @@ describe("T3 browser developer instructions", () => { }); it("tracks the turn's MCP configuration rather than defaulting to on", () => { - const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" }; NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/); NodeAssert.doesNotMatch( buildCodexDeveloperInstructions("default", runtime, false), From 62e74cdd9a0aa4df5fc612d94193da9610129dc9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:29:44 -0700 Subject: [PATCH 089/320] refactor(shared): remove unused elapsed-time adapter (#10046) --- apps/web/src/session-logic.ts | 2 +- packages/shared/src/orchestrationTiming.test.ts | 8 +------- packages/shared/src/orchestrationTiming.ts | 10 ---------- 3 files changed, 2 insertions(+), 18 deletions(-) diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 258d643efd5a..b6a6eb0e6342 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -35,7 +35,7 @@ import { type TurnDiffSummary, } from "./types"; -export { formatDuration, formatElapsed } from "@t3tools/shared/orchestrationTiming"; +export { formatDuration } from "@t3tools/shared/orchestrationTiming"; export type WorkLogToolLifecycleStatus = | "inProgress" diff --git a/packages/shared/src/orchestrationTiming.test.ts b/packages/shared/src/orchestrationTiming.test.ts index 7703421d5c29..dab35ad3e08c 100644 --- a/packages/shared/src/orchestrationTiming.test.ts +++ b/packages/shared/src/orchestrationTiming.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { formatDuration, formatElapsed } from "./orchestrationTiming.ts"; +import { formatDuration } from "./orchestrationTiming.ts"; describe("formatDuration", () => { it.each([ @@ -29,9 +29,3 @@ describe("formatDuration", () => { expect(formatDuration(durationMs)).toBe("0ms"); }); }); - -describe("formatElapsed", () => { - it("formats a long run across midnight", () => { - expect(formatElapsed("2026-09-03T22:00:00Z", "2026-09-04T04:59:50Z")).toBe("6h 59m 50s"); - }); -}); diff --git a/packages/shared/src/orchestrationTiming.ts b/packages/shared/src/orchestrationTiming.ts index 956226e19a7d..98829ae052ad 100644 --- a/packages/shared/src/orchestrationTiming.ts +++ b/packages/shared/src/orchestrationTiming.ts @@ -28,16 +28,6 @@ export function formatDuration(durationMs: number): string { return parts.join(" "); } -export function formatElapsed(startIso: string, endIso: string | undefined): string | null { - if (!endIso) return null; - const startedAt = Date.parse(startIso); - const endedAt = Date.parse(endIso); - if (Number.isNaN(startedAt) || Number.isNaN(endedAt) || endedAt < startedAt) { - return null; - } - return formatDuration(endedAt - startedAt); -} - export function isLatestTurnSettled( latestTurn: LatestTurnTiming | null, session: SessionActivityState | null, From 91ba05e870603b6bc0649e33973f73d86cbf58c3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:30:07 -0700 Subject: [PATCH 090/320] refactor(web): remove unused preview thread reset helper (#10049) --- apps/web/src/previewStateStore.test.ts | 9 --------- apps/web/src/previewStateStore.ts | 7 ------- 2 files changed, 16 deletions(-) diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 321dd68c09aa..862fe46081e2 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -18,7 +18,6 @@ import { readThreadPreviewState, reconcilePreviewServerSessions, rememberPreviewUrl, - removePreviewThread, resetPreviewStateForTests, subscribeThreadPreviewState, setActivePreviewTab, @@ -609,12 +608,4 @@ describe("previewStateStore (single-tab)", () => { `http://localhost:${5000 + __testing.RECENT_URL_LIMIT + 4}/`, ); }); - - it("removeThread strips the entry", () => { - const snapshot = makeSnapshot(); - applyPreviewServerSnapshot(ref, snapshot); - removePreviewThread(ref); - const state = readThreadPreviewState(ref); - expect(state).toEqual(__testing.EMPTY_THREAD_PREVIEW_STATE); - }); }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index eb1052feeb87..90f8e27c3588 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -472,13 +472,6 @@ export function rememberPreviewUrl(ref: ScopedThreadRef, url: string): void { })); } -export function removePreviewThread(ref: ScopedThreadRef): void { - const threadKey = scopedThreadKey(ref); - appAtomRegistry.set(previewStateAtom(threadKey), EMPTY_THREAD_PREVIEW_STATE); - syncActivePreviewThread(threadKey, EMPTY_THREAD_PREVIEW_STATE); - changedPreviewThreadKeys.delete(threadKey); -} - export function isPreviewSupportedInRuntime(): boolean { if (typeof window === "undefined") return false; return Boolean(window.desktopBridge?.preview); From 56a2f42b8ce27f5deefaa85a02ae9c1d3b61f309 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:30:18 -0700 Subject: [PATCH 091/320] refactor(desktop): remove test-only error predicates (#10047) --- apps/desktop/src/backend/DesktopServerExposure.test.ts | 3 --- apps/desktop/src/backend/DesktopServerExposure.ts | 2 -- apps/desktop/src/ipc/DesktopIpc.test.ts | 2 -- apps/desktop/src/ipc/DesktopIpc.ts | 1 - apps/desktop/src/preview/BrowserSession.test.ts | 6 ------ apps/desktop/src/preview/BrowserSession.ts | 2 -- apps/desktop/src/updates/DesktopUpdates.test.ts | 1 - apps/desktop/src/updates/DesktopUpdates.ts | 1 - 8 files changed, 18 deletions(-) diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d1..4a8b516cb936 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -272,8 +272,6 @@ describe("DesktopServerExposure", () => { modeError, DesktopServerExposure.DesktopServerExposureModePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError)); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError)); assert.equal(modeError.mode, "network-accessible"); assert.strictEqual(modeError.cause, settingsFailure); assert.strictEqual(modeError.cause.cause, diskFailure); @@ -290,7 +288,6 @@ describe("DesktopServerExposure", () => { tailscaleError, DesktopServerExposure.DesktopTailscaleServePersistenceError, ); - assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError)); assert.equal(tailscaleError.enabled, true); assert.equal(tailscaleError.port, 8443); assert.strictEqual(tailscaleError.cause, settingsFailure); diff --git a/apps/desktop/src/backend/DesktopServerExposure.ts b/apps/desktop/src/backend/DesktopServerExposure.ts index f04d2af7b1f6..6c3cd55527eb 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.ts @@ -244,7 +244,6 @@ export const DesktopServerExposureSetModeError = Schema.Union([ DesktopServerExposureModePersistenceError, ]); export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type; -export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError); export const DesktopServerExposureError = Schema.Union([ DesktopServerExposureNoNetworkAddressError, @@ -252,7 +251,6 @@ export const DesktopServerExposureError = Schema.Union([ DesktopTailscaleServePersistenceError, ]); export type DesktopServerExposureError = typeof DesktopServerExposureError.Type; -export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError); export interface DesktopServerExposureBackendConfig { readonly port: number; diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts index fc311877f829..5533831f9b55 100644 --- a/apps/desktop/src/ipc/DesktopIpc.test.ts +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -41,7 +41,6 @@ describe("DesktopIpc", () => { const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "invoke"); assert.strictEqual(error.channel, invokeMethod.channel); assert.strictEqual(error.cause, cause); @@ -69,7 +68,6 @@ describe("DesktopIpc", () => { if (exit._tag === "Success") return; const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); - assert.isTrue(DesktopIpc.isDesktopIpcError(error)); assert.strictEqual(error.handlerKind, "sync"); assert.strictEqual(error.channel, syncMethod.channel); assert.strictEqual(error.cause, cause); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index e948571cc628..643543d4ec33 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -55,7 +55,6 @@ export const DesktopIpcError = Schema.Union([ DesktopIpcUnregistrationError, ]); export type DesktopIpcError = typeof DesktopIpcError.Type; -export const isDesktopIpcError = Schema.is(DesktopIpcError); export interface DesktopIpcMethod { readonly channel: string; diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index ff22f3dd2272..aaf34c3578f9 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -172,8 +172,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-a"); assert.strictEqual(error.cause, platformCause); assert.strictEqual(error.cause.reason.cause, nativeCause); @@ -196,8 +194,6 @@ describe("BrowserSession", () => { const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); - assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); - assert.isTrue(BrowserSession.isBrowserSessionError(error)); assert.equal(error.scope, "environment-b"); assert.equal(error.partition, partition); assert.strictEqual(error.cause, cause); @@ -270,7 +266,6 @@ describe("BrowserSession", () => { const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); assert.equal(storageError.partition, secondPartition); assert.strictEqual(storageError.cause, storageCause); assert.equal( @@ -287,7 +282,6 @@ describe("BrowserSession", () => { const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); - assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); assert.equal(cacheError.partition, firstPartition); assert.strictEqual(cacheError.cause, cacheCause); assert.equal( diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7f3c9ec5d7ac..7ff879852283 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -93,7 +93,6 @@ export const BrowserSessionGetSessionError = Schema.Union([ BrowserSessionCreationError, ]); export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; -export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); export const BrowserSessionError = Schema.Union([ BrowserSessionPartitionDerivationError, @@ -102,7 +101,6 @@ export const BrowserSessionError = Schema.Union([ BrowserSessionCacheClearError, ]); export type BrowserSessionError = typeof BrowserSessionError.Type; -export const isBrowserSessionError = Schema.is(BrowserSessionError); export class BrowserSession extends Context.Service< BrowserSession, diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 1978337df3e7..509778521511 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -794,7 +794,6 @@ describe("DesktopUpdates", () => { const error = yield* updates.setChannel("nightly").pipe(Effect.flip); assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); - assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); assert.equal(error.channel, "nightly"); assert.strictEqual(error.cause, settingsFailure); assert.strictEqual(error.cause.cause, diskFailure); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 20f005f2ab2d..344d135a1024 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -155,7 +155,6 @@ export const DesktopUpdateSetChannelError = Schema.Union([ DesktopUpdateChannelPersistenceError, ]); export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; -export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, From c2aff911c37f94558ab273ca3d44bb4854d01714 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:30:22 -0700 Subject: [PATCH 092/320] refactor(mobile): keep review reset hashing private (#10048) --- .../review/reviewDiffBridgeKeys.test.ts | 19 ++++++++----------- .../features/review/reviewDiffBridgeKeys.ts | 2 +- .../review/useNativeReviewDiffBridge.ts | 2 +- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts index 9b39a51dc90f..406665def071 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; describe("native review diff bridge", () => { - it("builds stable reset keys from the rendered diff identity", () => { + it("changes reset keys when the rendered diff identity changes", () => { const input = { threadKey: "env:thread", sectionId: "turn:2", @@ -13,15 +13,12 @@ describe("native review diff bridge", () => { rowCount: 4, }; - expect(buildNativeReviewTokensResetKey(input)).toBe(buildNativeReviewTokensResetKey(input)); - expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe( - buildNativeReviewTokensResetKey(input), - ); - expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).toContain(":empty:"); - }); + const resetKey = buildNativeReviewTokensResetKey(input); - it("includes diff length in the hash key to reduce accidental collisions", () => { - expect(hashReviewDiffKey("abc")).toMatch(/^3:/); - expect(hashReviewDiffKey("abcd")).toMatch(/^4:/); + expect( + buildNativeReviewTokensResetKey({ ...input, diff: "diff --git a/b.ts b/b.ts" }), + ).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, rowCount: 5 })).not.toBe(resetKey); + expect(buildNativeReviewTokensResetKey({ ...input, diff: null })).not.toBe(resetKey); }); }); diff --git a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts index d04534003b37..6c7c1e545785 100644 --- a/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts +++ b/apps/mobile/src/features/review/reviewDiffBridgeKeys.ts @@ -3,7 +3,7 @@ import type { NativeReviewDiffHighlightScheme } from "../diffs/nativeReviewDiffH // Pure key-derivation helpers for the native review diff bridge. Kept free of // react-native / hook imports so they stay unit-testable in node. -export function hashReviewDiffKey(diff: string | null | undefined): string { +function hashReviewDiffKey(diff: string | null | undefined): string { if (!diff) { return "empty"; } diff --git a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts index c6a656e012f7..1728da662686 100644 --- a/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts +++ b/apps/mobile/src/features/review/useNativeReviewDiffBridge.ts @@ -8,7 +8,7 @@ import { useNativeReviewDiffHighlighting } from "./useNativeReviewDiffHighlighti import { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; import { useUniwindTheme } from "../../lib/useUniwindTheme"; -export { buildNativeReviewTokensResetKey, hashReviewDiffKey } from "./reviewDiffBridgeKeys"; +export { buildNativeReviewTokensResetKey } from "./reviewDiffBridgeKeys"; export function useNativeReviewDiffBridge(input: { readonly threadKey: string | null; From a98dad77e15ef37d7fd6fc21e73434527a1fb21d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:31:18 -0700 Subject: [PATCH 093/320] test(web): remove AppRoot element order snapshot (#10052) --- apps/web/src/AppRoot.test.tsx | 26 -------------------------- 1 file changed, 26 deletions(-) delete mode 100644 apps/web/src/AppRoot.test.tsx diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx deleted file mode 100644 index 791004b74fad..000000000000 --- a/apps/web/src/AppRoot.test.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { Children, isValidElement, type ReactElement, type ReactNode } from "react"; -import { RouterProvider } from "@tanstack/react-router"; -import { describe, expect, it } from "vite-plus/test"; - -import { ElectronBrowserHost } from "./browser/ElectronBrowserHost"; -import { PreviewAutomationHosts } from "./components/preview/PreviewAutomationHosts"; -import { QuitHoldOverlay } from "./components/QuitHoldOverlay"; -import { AppAtomRegistryProvider } from "./rpc/atomRegistry"; -import type { AppRouter } from "./router"; -import { AppRoot } from "./AppRoot"; - -describe("AppRoot", () => { - it("shares the application atom registry with routed UI and renderer-wide desktop hosts", () => { - const root = AppRoot({ router: {} as AppRouter }); - - expect(root.type).toBe(AppAtomRegistryProvider); - const children = Children.toArray( - (root as ReactElement<{ readonly children: ReactNode }>).props.children, - ); - expect(children).toHaveLength(4); - expect(isValidElement(children[0]) && children[0].type).toBe(RouterProvider); - expect(isValidElement(children[1]) && children[1].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[2]) && children[2].type).toBe(ElectronBrowserHost); - expect(isValidElement(children[3]) && children[3].type).toBe(QuitHoldOverlay); - }); -}); From 62e4ae400555e62a42d5cb401d744ba6dd0b401b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:38:51 -0700 Subject: [PATCH 094/320] refactor(codex): keep app-server client internals private (#10035) --- .../src/_internal/shared.ts | 2 +- packages/effect-codex-app-server/src/client.ts | 7 +------ packages/effect-codex-app-server/src/errors.ts | 17 ++++++++--------- 3 files changed, 10 insertions(+), 16 deletions(-) diff --git a/packages/effect-codex-app-server/src/_internal/shared.ts b/packages/effect-codex-app-server/src/_internal/shared.ts index 34155348abfa..8bcb59467d3d 100644 --- a/packages/effect-codex-app-server/src/_internal/shared.ts +++ b/packages/effect-codex-app-server/src/_internal/shared.ts @@ -5,7 +5,7 @@ import * as CodexError from "../errors.ts"; export const JsonRpcId = Schema.Union([Schema.Number, Schema.String]); -export const JsonRpcError = Schema.Struct({ +const JsonRpcError = Schema.Struct({ code: Schema.Number, message: Schema.String, data: Schema.optional(Schema.Unknown), diff --git a/packages/effect-codex-app-server/src/client.ts b/packages/effect-codex-app-server/src/client.ts index c0cb5b1dc23a..78d719626139 100644 --- a/packages/effect-codex-app-server/src/client.ts +++ b/packages/effect-codex-app-server/src/client.ts @@ -84,7 +84,7 @@ type ServerNotificationHandler = ( payload: unknown, ) => Effect.Effect; -export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( +const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make")(function* ( stdio: Stdio.Stdio, options: CodexAppServerClientOptions = {}, terminationError?: Effect.Effect, @@ -250,11 +250,6 @@ export const make = Effect.fn("effect-codex-app-server/CodexAppServerClient.make }); }); -export const layer = ( - stdio: Stdio.Stdio, - options: CodexAppServerClientOptions = {}, -): Layer.Layer => Layer.effect(CodexAppServerClient, make(stdio, options)); - export const layerChildProcess = ( handle: ChildProcessSpawner.ChildProcessHandle, options: CodexAppServerClientOptions = {}, diff --git a/packages/effect-codex-app-server/src/errors.ts b/packages/effect-codex-app-server/src/errors.ts index 3803b4d40659..f0bf470c251c 100644 --- a/packages/effect-codex-app-server/src/errors.ts +++ b/packages/effect-codex-app-server/src/errors.ts @@ -1,15 +1,15 @@ import * as Schema from "effect/Schema"; import type * as SchemaIssue from "effect/SchemaIssue"; -export const CodexAppServerRequestOperation = Schema.Literals([ +const CodexAppServerRequestOperation = Schema.Literals([ "decode-payload", "encode-payload", "handle-request", "receive-response", ]); -export type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; +type CodexAppServerRequestOperation = typeof CodexAppServerRequestOperation.Type; -export const CodexAppServerSchemaIssueKind = Schema.Literals([ +const CodexAppServerSchemaIssueKind = Schema.Literals([ "Filter", "Encoding", "Pointer", @@ -22,9 +22,9 @@ export const CodexAppServerSchemaIssueKind = Schema.Literals([ "Forbidden", "OneOf", ]); -export type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; +type CodexAppServerSchemaIssueKind = typeof CodexAppServerSchemaIssueKind.Type; -export interface CodexAppServerSchemaIssueDiagnostics { +interface CodexAppServerSchemaIssueDiagnostics { readonly issueCount: number; readonly issueKinds: ReadonlyArray; readonly maximumPathDepth: number; @@ -62,7 +62,7 @@ const schemaIssueDiagnostics = (root: SchemaIssue.Issue): CodexAppServerSchemaIs }; }; -export const CodexAppServerPayloadKind = Schema.Literals([ +const CodexAppServerPayloadKind = Schema.Literals([ "null", "array", "string", @@ -74,7 +74,7 @@ export const CodexAppServerPayloadKind = Schema.Literals([ "function", "undefined", ]); -export type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; +type CodexAppServerPayloadKind = typeof CodexAppServerPayloadKind.Type; const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { if (payload === null) return "null"; @@ -84,8 +84,7 @@ const payloadKind = (payload: unknown): CodexAppServerPayloadKind => { const protocolMessageFields = ["id", "method", "params", "result", "error"] as const; -export const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); -export type CodexAppServerProtocolMessageField = typeof CodexAppServerProtocolMessageField.Type; +const CodexAppServerProtocolMessageField = Schema.Literals(protocolMessageFields); export interface CodexAppServerRequestDiagnostics { readonly method?: string; From 4631000f5a7666c88402ce11a9ebb8cdef7a7dad Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:38:51 -0700 Subject: [PATCH 095/320] ci: reject unused Codex client exports with Knip (#10036) --- docs/operations/development.md | 3 ++- package.json | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index de5c6c64e3ab..39686807d6dd 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -72,7 +72,8 @@ Windows investigation while that suite is not a required gate. ### Unused code `vp run knip:check` checks unused files and dependencies across the repo, then -unused exports and types in `packages/tailscale`. CI enforces both checks. +unused exports and types in `packages/tailscale` and `packages/effect-codex-app-server`. +CI enforces both checks. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. The full export audit still has findings and is not a repo-wide CI gate. Extend the diff --git a/package.json b/package.json index 2882a1f9ab7e..4fc0dafeb0e9 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --exports --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/tailscale --workspace packages/effect-codex-app-server --exports --no-config-hints", "knip:production": "knip --production", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From 45f5a5ffb3257d510f249b14c36a514beff2868a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:47:36 -0700 Subject: [PATCH 096/320] refactor(server): simplify native telemetry error internals (#10057) --- .../NativeTelemetryClient.test.ts | 42 ------------------- .../NativeTelemetryClient.ts | 10 ++--- 2 files changed, 3 insertions(+), 49 deletions(-) diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts index 8a595bc8b480..7d365d0c0bac 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.test.ts @@ -1,6 +1,5 @@ import type { HostPowerSnapshot } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Cause from "effect/Cause"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -9,12 +8,9 @@ import * as Ref from "effect/Ref"; import * as Semaphore from "effect/Semaphore"; import { - NativeTelemetryRequestTimedOut, - NativeTelemetryStreamClosed, canCommandNativeTelemetrySidecar, canRequestNativeTelemetryRetry, commitCollectionControlUpdate, - nativeTelemetrySupervisorFailureMessage, retainRecentNativeTelemetryFailures, resolveNativeSampleIntervalMs, synchronizeCollectionControlOnStart, @@ -82,44 +78,6 @@ describe("canCommandNativeTelemetrySidecar", () => { }); }); -describe("NativeTelemetryRequestTimedOut", () => { - it("models history and sample request deadlines without a fabricated cause", () => { - const historyTimeout = new NativeTelemetryRequestTimedOut({ - operation: "readHistory", - timeoutMs: 15_000, - }); - const sampleTimeout = new NativeTelemetryRequestTimedOut({ - operation: "sampleNow", - timeoutMs: 5_000, - }); - - expect(historyTimeout.message).toBe( - "Resource monitor 'readHistory' request timed out after 15000ms.", - ); - expect(sampleTimeout.message).toBe( - "Resource monitor 'sampleNow' request timed out after 5000ms.", - ); - expect("cause" in historyTimeout).toBe(false); - expect("cause" in sampleTimeout).toBe(false); - }); -}); - -describe("native telemetry supervisor failures", () => { - it("distinguishes a closed event stream from a process exit", () => { - expect(new NativeTelemetryStreamClosed().message).toBe( - "Resource monitor event stream closed unexpectedly.", - ); - }); - - it("keeps defect details out of the caller-visible health message", () => { - const secret = "credential=do-not-expose"; - const message = nativeTelemetrySupervisorFailureMessage(Cause.die(new Error(secret))); - - expect(message).toBe("Resource monitor supervisor stopped unexpectedly."); - expect(message).not.toContain(secret); - }); -}); - describe("retainRecentNativeTelemetryFailures", () => { it("expires old failures so an isolated crash restarts from the initial backoff", () => { expect(retainRecentNativeTelemetryFailures([0, 30_000], 90_001)).toEqual([]); diff --git a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts index 232079d9dc9b..4af8f1b762d5 100644 --- a/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts +++ b/apps/server/src/resourceTelemetry/NativeTelemetryClient.ts @@ -73,7 +73,7 @@ export class NativeTelemetryHandshakeTimedOut extends Schema.TaggedErrorClass()( +class NativeTelemetryRequestTimedOut extends Schema.TaggedErrorClass()( "NativeTelemetryRequestTimedOut", { operation: Schema.Literals(["readHistory", "sampleNow"]), @@ -131,7 +131,7 @@ export class NativeTelemetryExited extends Schema.TaggedErrorClass()( +class NativeTelemetryStreamClosed extends Schema.TaggedErrorClass()( "NativeTelemetryStreamClosed", {}, ) { @@ -340,10 +340,6 @@ function errorMessage(error: NativeTelemetryClientError): string { return error.message; } -export function nativeTelemetrySupervisorFailureMessage(_cause: Cause.Cause): string { - return "Resource monitor supervisor stopped unexpectedly."; -} - export function canRequestNativeTelemetryRetry( status: ResourceTelemetrySourceStatus, hasHandle: boolean, @@ -734,7 +730,7 @@ export const make = Effect.fn("resourceTelemetry.nativeTelemetryClient.make")(fu ...current, status: "unavailable" as const, hello: Option.none(), - lastError: Option.some(nativeTelemetrySupervisorFailureMessage(cause)), + lastError: Option.some("Resource monitor supervisor stopped unexpectedly."), })).pipe( Effect.andThen(publishHealth), Effect.andThen( From 5b7f6bcfbcd5f150a67f323e0ec5aeb70b3c4009 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:49:06 -0700 Subject: [PATCH 097/320] refactor(mobile): remove write-only terminal font cache (#10058) --- .../appearance/AppearancePreferencesProvider.tsx | 4 +--- .../src/features/terminal/terminalUiState.test.ts | 10 ---------- .../src/features/terminal/terminalUiState.ts | 14 -------------- 3 files changed, 1 insertion(+), 27 deletions(-) diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index 79d67ebaa7c2..f161e654788b 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -37,7 +37,6 @@ import { getMobileUniwindThemeName, type MobileThemeRuntimeState, } from "../../../lib/mobileThemeRuntime"; -import { cacheTerminalFontSize } from "../../terminal/terminalUiState"; interface AppearancePreferencesContextValue { /** Effective values with base-size derivation applied. Use this for rendering. */ @@ -143,8 +142,7 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN useLayoutEffect(() => { selectedThemeIdsRef.current = themeIds; syncThemeRuntime(runtimeState); - cacheTerminalFontSize(appearance.terminalFontSize); - }, [appearance.terminalFontSize, runtimeState, syncThemeRuntime, themeIds]); + }, [runtimeState, syncThemeRuntime, themeIds]); const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { diff --git a/apps/mobile/src/features/terminal/terminalUiState.test.ts b/apps/mobile/src/features/terminal/terminalUiState.test.ts index 0bb3c1395915..6879fdfdbb20 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.test.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.test.ts @@ -2,9 +2,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { EnvironmentId, ThreadId } from "@t3tools/contracts"; import { - cacheTerminalFontSize, cacheTerminalGridSize, - getCachedTerminalFontSize, getCachedTerminalGridSize, resetTerminalUiStateCaches, } from "./terminalUiState"; @@ -14,14 +12,6 @@ describe("terminalUiState", () => { resetTerminalUiStateCaches(); }); - it("caches terminal font size using the shared normalization rules", () => { - expect(getCachedTerminalFontSize()).toBeNull(); - expect(cacheTerminalFontSize(8.5)).toBe(8.5); - expect(getCachedTerminalFontSize()).toBe(8.5); - expect(cacheTerminalFontSize(100)).toBe(14); - expect(getCachedTerminalFontSize()).toBe(14); - }); - it("stores terminal grid sizes per terminal target", () => { const primaryTarget = { environmentId: EnvironmentId.make("env-1"), diff --git a/apps/mobile/src/features/terminal/terminalUiState.ts b/apps/mobile/src/features/terminal/terminalUiState.ts index 2cac0bf52b9e..84274430e8a1 100644 --- a/apps/mobile/src/features/terminal/terminalUiState.ts +++ b/apps/mobile/src/features/terminal/terminalUiState.ts @@ -1,7 +1,5 @@ import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { DEFAULT_TERMINAL_FONT_SIZE, normalizeTerminalFontSize } from "./terminalPreferences"; - export interface TerminalGridSize { readonly cols: number; readonly rows: number; @@ -14,22 +12,11 @@ export interface TerminalUiStateTarget { } const terminalGridSizeCache = new Map(); -let cachedTerminalFontSize: number | null = null; function terminalUiStateKey(target: TerminalUiStateTarget): string { return `${target.environmentId}:${target.threadId}:${target.terminalId}`; } -export function getCachedTerminalFontSize(): number | null { - return cachedTerminalFontSize; -} - -export function cacheTerminalFontSize(value: number | null | undefined): number { - const normalized = normalizeTerminalFontSize(value ?? DEFAULT_TERMINAL_FONT_SIZE); - cachedTerminalFontSize = normalized; - return normalized; -} - export function getCachedTerminalGridSize(target: TerminalUiStateTarget): TerminalGridSize | null { return terminalGridSizeCache.get(terminalUiStateKey(target)) ?? null; } @@ -47,6 +34,5 @@ export function cacheTerminalGridSize( } export function resetTerminalUiStateCaches() { - cachedTerminalFontSize = null; terminalGridSizeCache.clear(); } From 80f775a42c448290c102e572fc889e4e4a1dd546 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:49:44 -0700 Subject: [PATCH 098/320] refactor(web): remove obsolete HSL theme generator (#10061) --- apps/web/src/themePalette.test.ts | 66 +++----- apps/web/src/themePalette.ts | 247 ------------------------------ 2 files changed, 19 insertions(+), 294 deletions(-) diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts index a76885a862ee..e1d9bfb74cab 100644 --- a/apps/web/src/themePalette.test.ts +++ b/apps/web/src/themePalette.test.ts @@ -34,7 +34,6 @@ import { OCEAN_THEME, updateCustomTheme, CUSTOM_THEMES_STORAGE_KEY, - createManagedThemeColors, createVividThemeColors, getDefaultThemeColors, themeColorToHex, @@ -91,50 +90,6 @@ describe("theme files", () => { } }); - it("derives a readable palette from extreme simple-editor colors", () => { - const light = createManagedThemeColors("light", "#111827", "#ffff00"); - const dark = createManagedThemeColors("dark", "#ffffff", "#ffff00"); - const darkDefaults = getDefaultThemeColors("dark"); - - expect(asHex(light.canvas)).not.toBe("#111827"); - expect(asHex(dark.canvas)).not.toBe("#ffffff"); - expect(contrastRatio(light.accent, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accent, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeLessThan(5.5); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeLessThan(5.5); - expect(contrastRatio(light.textMuted, light.canvas)).toBeCloseTo(4.705, 1); - expect(contrastRatio(dark.textMuted, dark.canvas)).toBeCloseTo(5.082, 1); - expect(light.secondaryLabel).toBe(light.textMuted); - expect(dark.secondaryLabel).toBe(dark.textMuted); - expect(contrastRatio(light.accentForeground, light.accent)).toBeGreaterThanOrEqual(4.5); - expect(contrastRatio(dark.accentForeground, dark.accent)).toBeGreaterThanOrEqual(4.5); - // Status colors fall back to T3 Code's standard red and amber rather than - // the flagship palette's, so no generated theme inherits a brand tint. - const channels = (value: string) => - [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ - number, - number, - number, - ]; - for (const colors of [light, dark]) { - const [errorRed, errorGreen, errorBlue] = channels(colors.error); - // Red leads by a wide margin; the old default was a pink whose blue sat - // close behind its red. - expect(errorRed).toBeGreaterThan(errorGreen * 2); - expect(errorRed).toBeGreaterThan(errorBlue * 2); - expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); - expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( - 4.5, - ); - const [warnRed, warnGreen, warnBlue] = channels(colors.warning); - expect(warnRed).toBeGreaterThan(warnBlue); - expect(warnGreen).toBeGreaterThan(warnBlue); - } - expect(asHex(dark.error)).not.toBe(asHex(darkDefaults.error)); - }); - it("keeps stock dark controls in the neutral-black surface hierarchy", () => { expectThemeColors(getStandardThemeColors("dark"), { canvas: "#0a0a0a", @@ -158,6 +113,12 @@ describe("theme files", () => { ["light", "#111827", "#8ab4f8"], ["dark", "#f5ecf5", "#a84370"], ]; + const channels = (value: string) => + [1, 3, 5].map((index) => Number.parseInt(asHex(value).slice(index, index + 2), 16)) as [ + number, + number, + number, + ]; for (const [appearance, canvas, accent] of seeds) { const colors = createVividThemeColors(appearance, canvas, accent); // Exact seeds are honored. @@ -193,6 +154,17 @@ describe("theme files", () => { expect(colors.messageAction).not.toBe(colors.accent); // Update family follows the theme, not the default palette. expect(asHex(colors.update)).toBe(accent); + // Semantic statuses stay red and amber instead of inheriting a brand tint. + const [errorRed, errorGreen, errorBlue] = channels(colors.error); + expect(errorRed).toBeGreaterThan(errorGreen * 2); + expect(errorRed).toBeGreaterThan(errorBlue * 2); + expect(contrastRatio(colors.error, "#ffffff")).toBeGreaterThanOrEqual(2.5); + expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( + 4.5, + ); + const [warnRed, warnGreen, warnBlue] = channels(colors.warning); + expect(warnRed).toBeGreaterThan(warnBlue); + expect(warnGreen).toBeGreaterThan(warnBlue); } }); @@ -202,8 +174,8 @@ describe("theme files", () => { const inverted = [ createVividThemeColors("light", "#111827", "#8ab4f8"), createVividThemeColors("dark", "#f5ecf5", "#a84370"), - createManagedThemeColors("light", "#0d1117", "#69b1ff", { exactSeeds: true }), - createManagedThemeColors("dark", "#fdfdfd", "#c2571b", { exactSeeds: true }), + createVividThemeColors("light", "#0d1117", "#69b1ff"), + createVividThemeColors("dark", "#fdfdfd", "#c2571b"), ]; for (const colors of inverted) { expect(contrastRatio(colors.errorForeground, colors.errorSurface)).toBeGreaterThanOrEqual( diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f67eb943e3fb..f4b5c83df395 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -475,12 +475,6 @@ type ThemeRgbColor = { b: number; }; -type ThemeHslColor = { - h: number; - s: number; - l: number; -}; - type ThemeOklch = { L: number; C: number; h: number }; type ParsedThemeColor = { color: ThemeOklch; alpha: number }; @@ -601,48 +595,6 @@ function canonicalizeThemeDefinition(theme: ThemeDefinition): ThemeDefinition { }; } -function themeRgbToHsl(color: ThemeRgbColor): ThemeHslColor { - const red = color.r / 255; - const green = color.g / 255; - const blue = color.b / 255; - const max = Math.max(red, green, blue); - const min = Math.min(red, green, blue); - const delta = max - min; - const lightness = (max + min) / 2; - - if (delta === 0) return { h: 0, s: 0, l: lightness }; - - const saturation = delta / (1 - Math.abs(2 * lightness - 1)); - let hue = 0; - if (max === red) hue = ((green - blue) / delta) % 6; - else if (max === green) hue = (blue - red) / delta + 2; - else hue = (red - green) / delta + 4; - - return { h: (hue * 60 + 360) % 360, s: saturation, l: lightness }; -} - -function themeHslToRgb(color: ThemeHslColor): ThemeRgbColor { - const hue = ((color.h % 360) + 360) % 360; - const chroma = (1 - Math.abs(2 * color.l - 1)) * color.s; - const hueSector = hue / 60; - const secondary = chroma * (1 - Math.abs((hueSector % 2) - 1)); - const match = color.l - chroma / 2; - const [red, green, blue] = - hueSector < 1 - ? [chroma, secondary, 0] - : hueSector < 2 - ? [secondary, chroma, 0] - : hueSector < 3 - ? [0, chroma, secondary] - : hueSector < 4 - ? [0, secondary, chroma] - : hueSector < 5 - ? [secondary, 0, chroma] - : [chroma, 0, secondary]; - - return { r: (red + match) * 255, g: (green + match) * 255, b: (blue + match) * 255 }; -} - function mixThemeRgbColors( base: ThemeRgbColor, overlay: ThemeRgbColor, @@ -1046,205 +998,6 @@ function standardMutedThemeText( return readableThemeText(background, foreground, 1, target); } -function managedThemeBackground(value: string, appearance: ThemeAppearance): ThemeRgbColor { - const selected = parseThemeRgbColor( - value, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ); - const hsl = themeRgbToHsl(selected); - return themeHslToRgb({ - h: hsl.h, - // A background tint should support the selected mode, not turn the whole - // app into a high-saturation surface. - s: Math.min(hsl.s, appearance === "dark" ? 0.3 : 0.2), - l: - appearance === "dark" - ? Math.min(0.13, Math.max(0.07, hsl.l)) - : Math.min(0.985, Math.max(0.94, hsl.l)), - }); -} - -function managedThemeAccent( - value: string, - appearance: ThemeAppearance, - background: ThemeRgbColor, -): ThemeRgbColor { - const selected = parseThemeRgbColor(value, { r: 168, g: 67, b: 112 }); - const hsl = themeRgbToHsl(selected); - const preferredLightness = - appearance === "dark" - ? Math.min(0.72, Math.max(0.42, hsl.l)) - : Math.min(0.58, Math.max(0.35, hsl.l)); - const lightnessRange: readonly [number, number] = - appearance === "dark" ? [0.42, 0.82] : [0.22, 0.58]; - const saturation = Math.min(hsl.s, 0.82); - const candidates = Array.from({ length: 61 }, (_, index) => { - const lightness = - lightnessRange[0] + ((lightnessRange[1] - lightnessRange[0]) * index) / (61 - 1); - const color = themeHslToRgb({ h: hsl.h, s: saturation, l: lightness }); - return { color, lightness, contrast: themeContrastRatio(color, background) }; - }); - // Leave a little room for browser color conversion at render time. - const readableCandidates = candidates.filter((candidate) => candidate.contrast >= 4.7); - const pool = readableCandidates.length > 0 ? readableCandidates : candidates; - - return pool.reduce((best, candidate) => { - const distance = Math.abs(candidate.lightness - preferredLightness); - const bestDistance = Math.abs(best.lightness - preferredLightness); - return distance < bestDistance || - (distance === bestDistance && candidate.contrast > best.contrast) - ? candidate - : best; - }).color; -} - -/** - * Creates the guided palette used by the basic theme editor. The two user - * colors control the mood, while dependent roles are generated together so - * text, surfaces, message actions, code, and terminal UI stay coherent. - */ -export function createManagedThemeColors( - appearance: ThemeAppearance, - backgroundValue: string, - accentValue: string, - options?: { - /** Use the seeds exactly as given instead of nudging them into the - * readability envelope. Derived foregrounds still adapt for contrast. */ - exactSeeds?: boolean; - }, -): ThemeColors { - const defaults = getDefaultThemeColors(appearance); - const canvas = options?.exactSeeds - ? parseThemeRgbColor( - backgroundValue, - appearance === "dark" ? { r: 24, g: 15, b: 27 } : { r: 250, g: 245, b: 250 }, - ) - : managedThemeBackground(backgroundValue, appearance); - const accent = options?.exactSeeds - ? parseThemeRgbColor(accentValue, { r: 168, g: 67, b: 112 }) - : managedThemeAccent(accentValue, appearance, canvas); - const text = readableThemeForeground(canvas); - const textMuted = standardMutedThemeText(canvas, text); - // The top bar is part of the main panel, not a separate chrome layer: it - // shares the canvas, and its controls sit on the panel's own surfaces. - const chrome = canvas; - const sidebar = mixThemeRgbColors(canvas, accent, 0.08); - const surfaceRaised = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.12 : 0.035); - const surfaceOverlay = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.18 : 0.06); - const secondary = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.2 : 0.08); - const muted = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.13 : 0.06); - const mutedForeground = readableThemeText(muted, text, 1, 4.6); - const placeholder = readableThemeText(surfaceRaised, text, 1, 4.6); - const accentSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14); - const messageSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.36 : 0.18); - const toolbarControl = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.2 : 0.08); - const toolbarBorder = mixThemeRgbColors(chrome, accent, appearance === "dark" ? 0.35 : 0.14); - const accentForeground = readableThemeForeground(accent); - // Code and terminal are large surfaces: they keep the canvas hue instead of - // drifting toward the foreground grey. Code sits just above the canvas — - // a whisper of the text tint — and the terminal sits on the canvas itself. - const codeBackground = mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.06 : 0.025); - const terminalBackground = canvas; - const messageActionHover = mixThemeRgbColors( - accent, - accentForeground === THEME_LIGHT_FOREGROUND || accentForeground === THEME_WHITE_FOREGROUND - ? THEME_BLACK_FOREGROUND - : THEME_WHITE_FOREGROUND, - 0.12, - ); - - // The update family follows the accent instead of inheriting the default - // palette's brand color, so generated themes carry their own identity in - // update pills and banners. Error and warning stay semantic defaults. - const updateSurface = mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.32 : 0.16); - const updateForeground = mixThemeRgbColors( - accent, - appearance === "dark" ? THEME_WHITE_FOREGROUND : THEME_BLACK_FOREGROUND, - 0.35, - ); - - return { - ...defaults, - ...standardStatusColors(canvas), - update: themeRgbToThemeColor(accent), - updateForeground: themeRgbToThemeColor(updateForeground), - updateSurface: themeRgbToThemeColor(updateSurface), - canvas: themeRgbToThemeColor(canvas), - chrome: themeRgbToThemeColor(chrome), - toolbar: themeRgbToThemeColor(chrome), - toolbarForeground: themeRgbToThemeColor(text), - toolbarBorder: themeRgbToThemeColor(toolbarBorder), - toolbarControl: themeRgbToThemeColor(toolbarControl), - toolbarControlForeground: themeRgbToThemeColor(text), - toolbarControlHover: themeRgbToThemeColor(accentSurface), - surface: themeRgbToThemeColor(canvas), - surfaceRaised: themeRgbToThemeColor(surfaceRaised), - surfaceOverlay: themeRgbToThemeColor(surfaceOverlay), - text: themeRgbToThemeColor(text), - textMuted: themeRgbToThemeColor(textMuted), - // Borders blend through the accent before lightening so control chrome - // carries the theme hue like the hand-tuned palettes (#5c345b, #e0d3e1) - // instead of flattening to grey. - border: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.22 : 0.1), - text, - 0.1, - ), - ), - input: themeRgbToThemeColor( - mixThemeRgbColors( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.3 : 0.14), - text, - appearance === "dark" ? 0.14 : 0.13, - ), - ), - focus: themeRgbToThemeColor(accent), - accent: themeRgbToThemeColor(accent), - accentForeground: themeRgbToThemeColor(accentForeground), - secondary: themeRgbToThemeColor(secondary), - secondaryForeground: themeRgbToThemeColor(readableThemeForeground(secondary)), - muted: themeRgbToThemeColor(muted), - mutedForeground: themeRgbToThemeColor(mutedForeground), - placeholder: themeRgbToThemeColor(placeholder), - secondaryLabel: themeRgbToThemeColor(textMuted), - iconMuted: themeRgbToThemeColor(textMuted), - accentSurface: themeRgbToThemeColor(accentSurface), - accentSurfaceForeground: themeRgbToThemeColor(readableThemeForeground(accentSurface)), - messageSurface: themeRgbToThemeColor(messageSurface), - messageForeground: themeRgbToThemeColor(readableThemeForeground(messageSurface)), - messageAction: themeRgbToThemeColor(accent), - messageActionForeground: themeRgbToThemeColor(accentForeground), - messageActionHover: themeRgbToThemeColor(messageActionHover), - codeBackground: themeRgbToThemeColor(codeBackground), - codeForeground: themeRgbToThemeColor(readableThemeForeground(codeBackground)), - sidebar: themeRgbToThemeColor(sidebar), - sidebarForeground: themeRgbToThemeColor(readableThemeForeground(sidebar)), - sidebarMutedForeground: themeRgbToThemeColor(standardMutedThemeText(sidebar, text)), - sidebarControlSurface: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.16 : 0.08), - ), - sidebarRowHover: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.12)), - sidebarRowActive: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.2)), - sidebarRowSelected: themeRgbToThemeColor(mixThemeRgbColors(sidebar, accent, 0.24)), - sidebarBorder: themeRgbToThemeColor( - mixThemeRgbColors(sidebar, text, appearance === "dark" ? 0.35 : 0.12), - ), - terminalBackground: themeRgbToThemeColor(terminalBackground), - terminalForeground: themeRgbToThemeColor(readableThemeForeground(terminalBackground)), - terminalCursor: themeRgbToThemeColor(accent), - terminalSelection: themeRgbToThemeColor( - mixThemeRgbColors(canvas, accent, appearance === "dark" ? 0.35 : 0.18), - ), - terminalScrollbar: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.42 : 0.22), - ), - terminalScrollbarHover: themeRgbToThemeColor( - mixThemeRgbColors(canvas, text, appearance === "dark" ? 0.55 : 0.32), - ), - }; -} - /** Theme-file defaults follow the flagship palette for the requested mode. */ export function getDefaultThemeColors(appearance: ThemeAppearance): ThemeColors { return appearance === "dark" ? T3_CHAT_THEME.variants!.dark! : T3_CHAT_THEME.colors; From d524eb96936090bd2b8a96449784155ad4af9591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:50:46 -0700 Subject: [PATCH 099/320] refactor(mobile): remove obsolete native diff token stream (#10062) --- .../diffs/nativeReviewDiffHighlighter.test.ts | 23 +---- .../diffs/nativeReviewDiffHighlighter.ts | 84 ------------------- 2 files changed, 1 insertion(+), 106 deletions(-) diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts index e7e75a4faa1a..24b18edd5db3 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.test.ts @@ -2,11 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import type { NativeReviewDiffRow } from "./nativeReviewDiffSurface"; import type { NativeReviewDiffFile } from "./nativeReviewDiffTypes"; -import { - highlightNativeReviewDiffVisibleRows, - streamNativeReviewDiffTokens, - type NativeReviewDiffTokenChunk, -} from "./nativeReviewDiffHighlighter"; +import { highlightNativeReviewDiffVisibleRows } from "./nativeReviewDiffHighlighter"; const tokenization = vi.hoisted(() => ({ calls: [] as string[], @@ -340,21 +336,4 @@ describe.each(["native", "javascript"] as const)("%s highlighting budgets", (eng expect(result.rowCount).toBe(0); expect(result.tokensByRowId).toEqual({}); }); - - it("applies the same long-line guard to streamed token chunks", async () => { - const content = "x".repeat(10_000); - const chunks: NativeReviewDiffTokenChunk[] = []; - - await streamNativeReviewDiffTokens({ - rows: [line(1, content)], - files: [TYPESCRIPT_FILE], - scheme: "dark", - engine, - onChunk: (chunk) => chunks.push(chunk), - }); - - expect(chunks).toHaveLength(1); - expect(chunks[0]?.tokensByRowId["line-1"]).toEqual([{ content, color: null, fontStyle: null }]); - expect(tokenization.calls).toHaveLength(0); - }); }); diff --git a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts index 0ea8c100dc11..e924ff1aa645 100644 --- a/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts +++ b/apps/mobile/src/features/diffs/nativeReviewDiffHighlighter.ts @@ -65,26 +65,6 @@ interface IndexedNativeReviewDiffLineRow { readonly rowIndex: number; } -export interface NativeReviewDiffTokenChunk { - readonly chunkIndex: number; - readonly fileId: string; - readonly filePath: string; - readonly language: NativeReviewDiffLanguage; - readonly lineCount: number; - readonly durationMs: number; - readonly tokensByRowId: Record>; -} - -export interface StreamNativeReviewDiffTokenInput { - readonly rows: ReadonlyArray; - readonly files: ReadonlyArray; - readonly scheme: NativeReviewDiffHighlightScheme; - readonly engine?: NativeReviewDiffHighlightEngine; - readonly chunkSize?: number; - readonly signal?: AbortSignal; - readonly onChunk: (chunk: NativeReviewDiffTokenChunk) => void; -} - export interface HighlightNativeReviewDiffVisibleRowsInput { readonly rows: ReadonlyArray; readonly files: ReadonlyArray; @@ -98,7 +78,6 @@ export interface HighlightNativeReviewDiffVisibleRowsInput { readonly signal?: AbortSignal; } -const NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE = 500; const NATIVE_REVIEW_DIFF_VISIBLE_OVERSCAN_ROWS = 160; const NATIVE_REVIEW_DIFF_VISIBLE_MAX_ROWS = 360; const NATIVE_REVIEW_DIFF_TOKENIZE_MAX_LINE_LENGTH = 1_000; @@ -423,20 +402,6 @@ function canShareGrammarContext( ); } -function groupLineRowsByFileId(rows: ReadonlyArray) { - const rowsByFileId = new Map(); - for (const row of rows) { - if (!isHighlightableLineRow(row)) { - continue; - } - - const fileRows = rowsByFileId.get(row.fileId) ?? []; - fileRows.push(row); - rowsByFileId.set(row.fileId, fileRows); - } - return rowsByFileId; -} - function createFileMap(files: ReadonlyArray) { return new Map(files.map((file) => [file.id, file])); } @@ -561,52 +526,3 @@ export async function highlightNativeReviewDiffVisibleRows( durationMs: Math.round(performance.now() - startedAt), }; } - -export async function streamNativeReviewDiffTokens( - input: StreamNativeReviewDiffTokenInput, -): Promise { - const highlighter = await getNativeReviewDiffHighlighter(input.engine ?? "native"); - const rowsByFileId = groupLineRowsByFileId(input.rows); - const theme = NATIVE_REVIEW_DIFF_THEME_NAME_BY_SCHEME[input.scheme]; - const chunkSize = input.chunkSize ?? NATIVE_REVIEW_DIFF_HIGHLIGHT_CHUNK_SIZE; - let chunkIndex = 0; - - for (const file of input.files) { - const fileRows = rowsByFileId.get(file.id) ?? []; - for (let startIndex = 0; startIndex < fileRows.length; startIndex += chunkSize) { - if (input.signal?.aborted) { - return highlighter.engine; - } - - const startedAt = performance.now(); - const chunkRows = fileRows.slice(startIndex, startIndex + chunkSize); - const code = chunkRows.map((row) => row.content).join("\n"); - const tokenLines = await highlighter.tokenize(code, { - lang: file.language, - theme, - signal: input.signal, - }); - if (input.signal?.aborted) return highlighter.engine; - const tokensByRowId: Record> = {}; - - chunkRows.forEach((row, rowIndex) => { - tokensByRowId[row.id] = tokenLines[rowIndex] ?? makePlainTokenFallback(row); - }); - - input.onChunk({ - chunkIndex, - fileId: file.id, - filePath: file.path, - language: file.language, - lineCount: chunkRows.length, - durationMs: Math.round(performance.now() - startedAt), - tokensByRowId, - }); - - chunkIndex += 1; - await waitForNextFrame(); - } - } - - return highlighter.engine; -} From bc24d98d1630104722abe6a4d839365d24e2aa27 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:51:43 -0700 Subject: [PATCH 100/320] test(server): remove title prompt editorial snapshots (#10063) --- .../TextGenerationPrompts.test.ts | 41 +------------------ 1 file changed, 1 insertion(+), 40 deletions(-) diff --git a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts index 253d88779827..05f45b4cb3b0 100644 --- a/apps/server/src/textGeneration/TextGenerationPrompts.test.ts +++ b/apps/server/src/textGeneration/TextGenerationPrompts.test.ts @@ -146,7 +146,7 @@ describe("buildBranchNamePrompt", () => { }); describe("buildThreadTitlePrompt", () => { - it("includes the user message and the title guidance rules", () => { + it("includes the user message without absent attachment metadata", () => { const result = buildThreadTitlePrompt({ message: "Investigate reconnect regressions after session restore", }); @@ -154,18 +154,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("User message:"); expect(result.prompt).toContain("Investigate reconnect regressions after session restore"); expect(result.prompt).not.toContain("Attachment metadata:"); - expect(result.prompt).toContain( - "Generate a title that will help the user recognize this T3 Code thread weeks later.", - ); - expect(result.prompt).toContain( - "Title the subject and outcome. Discard incidental instructions.", - ); - expect(result.prompt).toContain( - "Name the product change, not the mock, plan, report, branch, or PR used to produce it.", - ); - expect(result.prompt).not.toContain( - "Title should summarize the user's request, not restate it verbatim.", - ); }); it("includes attachment metadata when attachments are provided", () => { @@ -188,24 +176,6 @@ describe("buildThreadTitlePrompt", () => { expect(result.prompt).toContain("67890 bytes"); }); - it.each([ - { mode: "initial", previousTitle: undefined }, - { mode: "regeneration", previousTitle: "Open Projects in Desktop App" }, - ])( - "tells the $mode prompt not to title linked PRs from local git history", - ({ previousTitle }) => { - const result = buildThreadTitlePrompt({ - message: "$takeover https://github.com/pingdotgg/t3code/pull/8588", - ...(previousTitle === undefined ? {} : { previousTitle }), - }); - - expect(result.prompt).toContain( - "Local git history is not evidence of what a linked PR or issue is about.", - ); - expect(result.prompt).toContain('such as "Take Over PR 8588"'); - }, - ); - it("regenerates from recent thread contents and identifies the previous title", () => { const result = buildThreadTitlePrompt({ message: `USER:\nInvestigate reconnect regressions\n\nASSISTANT:\nThe remaining issue is stale session state`, @@ -216,15 +186,6 @@ describe("buildThreadTitlePrompt", () => { "Regenerate the title for an existing T3 Code thread so the user can recognize it weeks later.", ); expect(result.prompt).toContain('The previous title was "Investigate reconnect regressions".'); - expect(result.prompt).toContain( - "Read the USER messages first. Identify the latest explicit durable goal.", - ); - expect(result.prompt).toContain( - "Do not promote one assistant finding into the thread subject unless the user adopts it as a new goal.", - ); - expect(result.prompt).toContain( - 'A subagent-monitoring review that finds a Codex roster bug remains "Review Subagent Monitoring Risks,"', - ); expect(result.prompt).toContain("Thread contents:"); expect(result.prompt).toContain("The remaining issue is stale session state"); }); From 160e337a5c521bcaae94b386d22226f32242fdd0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:52:24 -0700 Subject: [PATCH 101/320] test(server): remove repeated runtime prompt interpolation cases (#10059) --- apps/server/src/provider/RuntimeInstructions.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/server/src/provider/RuntimeInstructions.test.ts b/apps/server/src/provider/RuntimeInstructions.test.ts index 350d8c73c150..320aa332c937 100644 --- a/apps/server/src/provider/RuntimeInstructions.test.ts +++ b/apps/server/src/provider/RuntimeInstructions.test.ts @@ -2,17 +2,6 @@ import { describe, expect, it } from "vite-plus/test"; import { buildRuntimeInstructions } from "./RuntimeInstructions.ts"; describe("buildRuntimeInstructions", () => { - it.each(["Codex", "Claude Code", "Cursor", "Grok", "OpenCode", "Antigravity"])( - "identifies the %s harness and describes media embedding", - (harness) => { - const instructions = buildRuntimeInstructions({ harness }); - expect(instructions).toContain(`running in T3 Code through the ${harness} harness.`); - expect(instructions).toContain("embed images and videos"); - expect(instructions).toContain("Markdown with absolute file paths"); - expect(instructions).not.toContain("undefined"); - }, - ); - it("keeps known model and effort metadata on one line", () => { expect( buildRuntimeInstructions({ From 43700b83e73fca3af7b63769876c409b5c6f5a3f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:53:19 -0700 Subject: [PATCH 102/320] refactor(web): observe preview tests through the live registry (#10064) --- apps/web/src/previewStateStore.test.ts | 4 ++-- apps/web/src/previewStateStore.ts | 13 ------------- 2 files changed, 2 insertions(+), 15 deletions(-) diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index 862fe46081e2..f9956f9c02c8 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -19,10 +19,10 @@ import { reconcilePreviewServerSessions, rememberPreviewUrl, resetPreviewStateForTests, - subscribeThreadPreviewState, setActivePreviewTab, updatePreviewServerSnapshot, } from "./previewStateStore"; +import { appAtomRegistry } from "./rpc/atomRegistry"; const environmentId = "env-1" as EnvironmentId; const ref = scopeThreadRef(environmentId, ThreadId.make("thread-1")); @@ -352,7 +352,7 @@ describe("previewStateStore (single-tab)", () => { }, }; let updateCount = 0; - const unsubscribe = subscribeThreadPreviewState(ref, () => { + const unsubscribe = appAtomRegistry.subscribe(previewStateAtom(scopedThreadKey(ref)), () => { updateCount += 1; }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index 90f8e27c3588..1e7ec3706618 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -173,19 +173,6 @@ export function readThreadPreviewState(ref: ScopedThreadRef): ThreadPreviewState return appAtomRegistry.get(previewStateAtom(scopedThreadKey(ref))); } -export function subscribeThreadPreviewState( - ref: ScopedThreadRef, - listener: (state: ThreadPreviewState, previous: ThreadPreviewState) => void, -): () => void { - const atom = previewStateAtom(scopedThreadKey(ref)); - let previous = appAtomRegistry.get(atom); - return appAtomRegistry.subscribe(atom, (state) => { - const prior = previous; - previous = state; - listener(state, prior); - }); -} - export function applyPreviewServerEvent(ref: ScopedThreadRef, event: PreviewEvent): void { updateThreadPreviewState(ref, (current) => { if (current.serverEpoch !== null && event.serverEpoch !== current.serverEpoch) return current; From fedf84ac7922e11be0971ac50817d7c4199b8e89 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:53:31 -0700 Subject: [PATCH 103/320] test(server): remove keybinding default assignment snapshot (#10065) --- apps/server/src/keybindings.test.ts | 27 --------------------------- 1 file changed, 27 deletions(-) diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 160755a8f9b2..ec7070809435 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -188,33 +188,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }).pipe(Effect.provide(makeKeybindingsLayer())), ); - it.effect("ships configurable thread navigation defaults", () => - Effect.sync(() => { - const defaultsByCommand = new Map( - Keybindings.DEFAULT_KEYBINDINGS.map((binding) => [binding.command, binding.key] as const), - ); - - assert.equal(defaultsByCommand.get("thread.previous"), "mod+shift+["); - assert.equal(defaultsByCommand.get("thread.next"), "mod+shift+]"); - assert.equal(defaultsByCommand.get("thread.copyReference"), "mod+shift+c"); - assert.equal(defaultsByCommand.get("thread.settle"), "mod+shift+s"); - assert.equal(defaultsByCommand.get("thread.pin"), "mod+shift+p"); - assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); - assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("themeEditor.toggle"), "mod+alt+shift+t"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); - assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); - assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); - assert.isFalse(defaultsByCommand.has("rightPanel.toggleMaximized")); - assert.equal(defaultsByCommand.get("rightPanel.close"), "mod+w"); - assert.equal(defaultsByCommand.get("terminal.splitVertical"), "mod+shift+d"); - assert.equal(defaultsByCommand.get("modelPicker.jump.1"), "mod+1"); - assert.equal(defaultsByCommand.get("modelPicker.jump.9"), "mod+9"); - }), - ); - it.effect("uses defaults in runtime when config is malformed without overriding file", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; From 4db2c542a8c5e6b6756fac08615e30b220b71a2f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:53:42 -0700 Subject: [PATCH 104/320] refactor(mobile): remove obsolete whole-file review highlighters (#10067) --- .../review/shikiReviewHighlighter.test.ts | 206 ++++----- .../features/review/shikiReviewHighlighter.ts | 408 +----------------- 2 files changed, 86 insertions(+), 528 deletions(-) diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts index be723040152a..6d36171d2711 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.test.ts @@ -1,131 +1,60 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import type { ReviewRenderableFile } from "./reviewModel"; -import { highlightCodeSnippet, highlightReviewFile } from "./shikiReviewHighlighter"; +import type { ReviewRenderableLineRow } from "./reviewModel"; +import { + highlightCodeSnippet, + highlightReviewSelectedLines, + highlightSourceFile, +} from "./shikiReviewHighlighter"; -function makeRenderableFile( - input: Partial & Pick, -): ReviewRenderableFile { - return { - id: input.path, - cacheKey: input.path, - previousPath: null, - changeType: "new", - additions: 0, - deletions: 0, - languageHint: null, - additionLines: [], - deletionLines: [], - rows: [], - ...input, - }; -} - -describe("highlightReviewFile", () => { - it("preserves one highlighted token row per diff line even without trailing newlines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example.txt", - additionLines: [ - 'const items = ["a"];', - 'expect(items).toEqual(["a"]);', - "const next = items.map((item) => item.toUpperCase());", - 'expect(next).toContain("A");', - ], +describe("highlightSourceFile", () => { + it("preserves one highlighted token row per source line without trailing newlines", async () => { + const lines = [ + 'const items = ["a"];', + 'expect(items).toEqual(["a"]);', + "const next = items.map((item) => item.toUpperCase());", + 'expect(next).toContain("A");', + ]; + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example.ts", + contents: lines.join("\n"), + theme: "light", }); - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(file.additionLines.length); - expect(highlighted.additionLines[0]?.map((token) => token.content).join("")).toBe( - file.additionLines[0], - ); - expect(highlighted.additionLines[1]?.map((token) => token.content).join("")).toBe( - file.additionLines[1], + expect(highlighted.map((tokens) => tokens.map((token) => token.content).join(""))).toEqual( + lines, ); - expect(highlighted.additionLines[2]?.map((token) => token.content).join("")).toBe( - file.additionLines[2], - ); - expect(highlighted.additionLines[3]?.map((token) => token.content).join("")).toBe( - file.additionLines[3], - ); - }); - - it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { - const file = makeRenderableFile({ - path: "apps/mobile/src/example-inline-diff.txt", - additionLines: ["const after = 2;"], - deletionLines: ["const before = 1;"], - rows: [ - { - kind: "line", - id: "delete-1", - change: "delete", - oldLineNumber: 1, - newLineNumber: null, - content: "const before = 1;", - additionTokenIndex: null, - deletionTokenIndex: 0, - comparison: { change: "add", tokenIndex: 0 }, - }, - { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, - content: "const after = 2;", - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: { change: "delete", tokenIndex: 0 }, - }, - ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.deletionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); - expect(highlighted.additionLines[0]?.some((token) => token.diffHighlight === true)).toBe(true); }); it("falls back to plain tokens for very long lines", async () => { const longLine = `const value = "${"a".repeat(1_100)}";`; - const file = makeRenderableFile({ - path: "apps/mobile/src/example-long-line.txt", - additionLines: [longLine], - rows: [ + + const highlighted = await highlightSourceFile({ + path: "apps/mobile/src/example-long-line.ts", + contents: longLine, + theme: "light", + }); + + expect(highlighted).toEqual([ + [ { - kind: "line", - id: "add-1", - change: "add", - oldLineNumber: null, - newLineNumber: 1, content: longLine, - additionTokenIndex: 0, - deletionTokenIndex: null, - comparison: null, + color: null, + fontStyle: null, }, ], - }); - - const highlighted = await highlightReviewFile(file, "light"); - - expect(highlighted.additionLines).toHaveLength(1); - expect(highlighted.additionLines[0]).toEqual([ - { - content: longLine, - color: null, - fontStyle: null, - }, ]); }); -}); -describe("highlightCodeSnippet", () => { - it("resolves language aliases and returns syntax-colored tokens", async () => { + it("initializes source and snippet highlighting without a warmup", async () => { + vi.resetModules(); + const highlighter = await import("./shikiReviewHighlighter"); const source = "const answer: number = 42;"; - const highlighted = await highlightCodeSnippet({ - code: source, - language: "ts", + + const highlighted = await highlighter.highlightSourceFile({ + path: "example.ts", + contents: source, theme: "dark", }); @@ -136,18 +65,56 @@ describe("highlightCodeSnippet", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); + expect( + await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), + ).toEqual(highlighted); }); }); -describe("highlightSourceFile", () => { - it("initializes source and snippet highlighting without a warmup", async () => { - vi.resetModules(); - const highlighter = await import("./shikiReviewHighlighter"); - const source = "const answer: number = 42;"; +describe("highlightReviewSelectedLines", () => { + it("adds word-alt diff emphasis for paired deletion and addition lines", async () => { + const lines: ReviewRenderableLineRow[] = [ + { + kind: "line", + id: "delete-1", + change: "delete", + oldLineNumber: 1, + newLineNumber: null, + content: "const before = 1;", + additionTokenIndex: null, + deletionTokenIndex: 0, + comparison: { change: "add", tokenIndex: 0 }, + }, + { + kind: "line", + id: "add-1", + change: "add", + oldLineNumber: null, + newLineNumber: 1, + content: "const after = 2;", + additionTokenIndex: 0, + deletionTokenIndex: null, + comparison: { change: "delete", tokenIndex: 0 }, + }, + ]; - const highlighted = await highlighter.highlightSourceFile({ - path: "example.ts", - contents: source, + const highlighted = await highlightReviewSelectedLines({ + filePath: "apps/mobile/src/example-inline-diff.txt", + lines, + theme: "light", + }); + + expect(highlighted["delete-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + expect(highlighted["add-1"]?.some((token) => token.diffHighlight === true)).toBe(true); + }); +}); + +describe("highlightCodeSnippet", () => { + it("resolves language aliases and returns syntax-colored tokens", async () => { + const source = "const answer: number = 42;"; + const highlighted = await highlightCodeSnippet({ + code: source, + language: "ts", theme: "dark", }); @@ -158,8 +125,5 @@ describe("highlightSourceFile", () => { .join(""), ).toBe(source); expect(highlighted.flat().some((token) => token.color !== null)).toBe(true); - expect( - await highlighter.highlightCodeSnippet({ code: source, language: "ts", theme: "dark" }), - ).toEqual(highlighted); }); }); diff --git a/apps/mobile/src/features/review/shikiReviewHighlighter.ts b/apps/mobile/src/features/review/shikiReviewHighlighter.ts index c684a6686430..8fa7f69a433c 100644 --- a/apps/mobile/src/features/review/shikiReviewHighlighter.ts +++ b/apps/mobile/src/features/review/shikiReviewHighlighter.ts @@ -17,7 +17,7 @@ import { resolveReviewHighlighterEnginePreference, type ReviewHighlighterEngine, } from "./reviewHighlighterEngine"; -import type { ReviewRenderableFile, ReviewRenderableLineRow } from "./reviewModel"; +import type { ReviewRenderableLineRow } from "./reviewModel"; import { applyDiffRangesToTokens, computeWordAltDiffRanges } from "./reviewWordDiffs"; export type ReviewDiffTheme = "light" | "dark"; @@ -43,17 +43,6 @@ export interface ReviewHighlightedToken { readonly diffHighlight?: boolean; } -export interface ReviewHighlightedFile { - readonly additionLines: ReadonlyArray>; - readonly deletionLines: ReadonlyArray>; -} - -export interface ReviewHighlightFileProgress { - readonly highlightedFile: ReviewHighlightedFile; - readonly complete: boolean; - readonly highlightedLineCount: number; -} - const SHIKI_THEME_NAME_BY_SCHEME = { light: "github-light-default", dark: "github-dark-default", @@ -64,16 +53,9 @@ const REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE = const REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE = resolveReviewHighlighterEnginePreference( REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, ); -const REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE = resolveReviewHighlighterBooleanFlag( - process.env.EXPO_PUBLIC_REVIEW_HIGHLIGHTER_DISABLE_CACHE, - false, -); -const REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT = 8; const REVIEW_HIGHLIGHT_CHUNK_LINE_THRESHOLD = 8; const REVIEW_HIGHLIGHT_CHUNK_SIZE = 200; const REVIEW_TOKENIZE_MAX_LINE_LENGTH = 1_000; -const highlightCache = new Map>(); -const resolvedHighlightCache = new Map(); const REVIEW_INITIAL_LANGUAGE_MODULES = [ bashLanguage, javascriptLanguage, @@ -204,22 +186,6 @@ type LoadedLanguageModule = { default: Parameters[0]; }; -function resolveReviewHighlighterBooleanFlag( - value: string | undefined, - defaultValue: boolean, -): boolean { - switch (value) { - case "1": - case "true": - return true; - case "0": - case "false": - return false; - default: - return defaultValue; - } -} - function isReviewHighlighterDebugLoggingEnabled(): boolean { return typeof __DEV__ !== "undefined" ? __DEV__ : false; } @@ -267,7 +233,6 @@ async function getHighlighter(): Promise { logReviewHighlighterDiagnostic("initializing", { configuredPreference: REVIEW_HIGHLIGHTER_ENGINE_ENV_VALUE, preference: REVIEW_HIGHLIGHTER_ENGINE_PREFERENCE, - resultCacheDisabled: REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE, }); const themes = [githubLightDefault, githubDarkDefault]; @@ -488,13 +453,6 @@ async function resolveLanguageFromPath( return candidate; } -async function resolveLanguage(file: ReviewRenderableFile): Promise { - return ( - resolveLoadedLanguageFromPath(file.path, file.languageHint) ?? - (await resolveLanguageFromPath(file.path, file.languageHint)) - ); -} - function normalizeHighlightedLines( tokenLines: ReadonlyArray>, ): ReadonlyArray> { @@ -507,84 +465,6 @@ function normalizeHighlightedLines( ); } -function makePlainHighlightedLines( - lines: ReadonlyArray, -): ReadonlyArray> { - return lines.map((line) => [ - { - content: stripTrailingNewline(line), - color: null, - fontStyle: null, - }, - ]); -} - -function applyWordAltDiffHighlightsToFile( - file: ReviewRenderableFile, - highlighted: ReviewHighlightedFile, -): ReviewHighlightedFile { - const nextAdditionLines = [...highlighted.additionLines]; - const nextDeletionLines = [...highlighted.deletionLines]; - const processedPairs = new Set(); - let changed = false; - - file.rows.forEach((row) => { - if (row.kind !== "line" || row.change === "context" || !row.comparison) { - return; - } - - const deletionTokenIndex = - row.change === "delete" - ? row.deletionTokenIndex - : row.comparison.change === "delete" - ? row.comparison.tokenIndex - : null; - const additionTokenIndex = - row.change === "add" - ? row.additionTokenIndex - : row.comparison.change === "add" - ? row.comparison.tokenIndex - : null; - - if (deletionTokenIndex === null || additionTokenIndex === null) { - return; - } - - const pairKey = `${deletionTokenIndex}:${additionTokenIndex}`; - if (processedPairs.has(pairKey)) { - return; - } - processedPairs.add(pairKey); - - const deletionLine = stripTrailingNewline(file.deletionLines[deletionTokenIndex] ?? ""); - const additionLine = stripTrailingNewline(file.additionLines[additionTokenIndex] ?? ""); - const ranges = computeWordAltDiffRanges({ deletionLine, additionLine }); - - if (ranges.deletion.length > 0) { - nextDeletionLines[deletionTokenIndex] = applyDiffRangesToTokens( - nextDeletionLines[deletionTokenIndex] ?? [], - ranges.deletion, - ); - changed = true; - } - - if (ranges.addition.length > 0) { - nextAdditionLines[additionTokenIndex] = applyDiffRangesToTokens( - nextAdditionLines[additionTokenIndex] ?? [], - ranges.addition, - ); - changed = true; - } - }); - - return changed - ? { - additionLines: nextAdditionLines, - deletionLines: nextDeletionLines, - } - : highlighted; -} - function applyWordAltDiffHighlightsToSelectedLines(input: { readonly lines: ReadonlyArray; readonly tokenMap: Record>; @@ -733,292 +613,6 @@ export async function highlightSourceFile(input: { return highlightLines(input.contents, language, SHIKI_THEME_NAME_BY_SCHEME[input.theme]); } -async function highlightPatchLinesInChunks(input: { - readonly lines: ReadonlyArray; - readonly language: string; - readonly theme: string; - readonly onChunk: ( - startIndex: number, - tokens: ReadonlyArray>, - ) => void; -}): Promise>> { - if (input.lines.length === 0) { - return []; - } - - const highlighter = await getHighlighter(); - const highlightedLines: Array> = []; - - for ( - let startIndex = 0; - startIndex < input.lines.length; - startIndex += REVIEW_HIGHLIGHT_CHUNK_SIZE - ) { - const lineChunk = input.lines.slice(startIndex, startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE); - const chunkTokens: Array> = []; - const tokenizableLines: string[] = []; - const tokenizableIndexes: number[] = []; - - lineChunk.forEach((line, index) => { - const strippedLine = stripTrailingNewline(line); - if (strippedLine.length > REVIEW_TOKENIZE_MAX_LINE_LENGTH) { - chunkTokens[index] = [{ content: strippedLine, color: null, fontStyle: null }]; - return; - } - - tokenizableIndexes.push(index); - tokenizableLines.push(strippedLine); - }); - - if (tokenizableLines.length > 0) { - const tokenLines = highlighter.codeToTokensBase(tokenizableLines.join("\n"), { - lang: input.language, - theme: input.theme, - }); - const normalizedTokenLines = normalizeHighlightedLines(tokenLines); - - tokenizableIndexes.forEach((chunkIndex, tokenIndex) => { - chunkTokens[chunkIndex] = normalizedTokenLines[tokenIndex] ?? []; - }); - } - - const completedChunk = lineChunk.map((_, index) => chunkTokens[index] ?? []); - highlightedLines.push(...completedChunk); - input.onChunk(startIndex, completedChunk); - - if (startIndex + REVIEW_HIGHLIGHT_CHUNK_SIZE < input.lines.length) { - await waitForNextFrame(); - } - } - - return highlightedLines; -} - -function getHighlightCacheKey(file: ReviewRenderableFile, theme: ReviewDiffTheme): string { - return `${SHIKI_THEME_NAME_BY_SCHEME[theme]}:${file.cacheKey}`; -} - -function storeResolvedHighlightedFile(cacheKey: string, highlighted: ReviewHighlightedFile): void { - if (resolvedHighlightCache.has(cacheKey)) { - resolvedHighlightCache.delete(cacheKey); - } - - resolvedHighlightCache.set(cacheKey, highlighted); - - while (resolvedHighlightCache.size > REVIEW_HIGHLIGHT_RESULT_CACHE_LIMIT) { - const oldestKey = resolvedHighlightCache.keys().next().value; - if (oldestKey === undefined) { - break; - } - resolvedHighlightCache.delete(oldestKey); - } -} - -export async function highlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - logReviewHighlighterDiagnostic("file highlight cache hit (resolved)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return resolved; - } - const cached = highlightCache.get(cacheKey); - if (cached) { - logReviewHighlighterDiagnostic("file highlight cache hit (pending)", { - fileId: file.id, - filePath: file.path, - theme, - }); - return cached; - } - } - - const promise = (async () => { - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines = await highlightLines( - joinPatchLines(file.additionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - const deletionLines = await highlightLines( - joinPatchLines(file.deletionLines), - language, - shikiTheme, - ); - await waitForNextFrame(); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - logReviewHighlighterDiagnostic("file highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - })(); - - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.set(cacheKey, promise); - } - return promise.finally(() => { - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - highlightCache.delete(cacheKey); - } - }); -} - -export async function streamHighlightReviewFile( - file: ReviewRenderableFile, - theme: ReviewDiffTheme, - onProgress: (progress: ReviewHighlightFileProgress) => void, -): Promise { - const shikiTheme = SHIKI_THEME_NAME_BY_SCHEME[theme]; - const cacheKey = getHighlightCacheKey(file, theme); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - const resolved = resolvedHighlightCache.get(cacheKey); - if (resolved) { - onProgress({ - highlightedFile: resolved, - complete: true, - highlightedLineCount: resolved.additionLines.length + resolved.deletionLines.length, - }); - return resolved; - } - } - - const startedAt = Date.now(); - logReviewHighlighterDiagnostic("file stream highlight start", { - fileId: file.id, - filePath: file.path, - theme, - additionLineCount: file.additionLines.length, - deletionLineCount: file.deletionLines.length, - rowCount: file.rows.length, - }); - - const loadedLanguage = resolveLoadedLanguageFromPath(file.path, file.languageHint); - const language = loadedLanguage ?? (await resolveLanguage(file)); - if (language === "text") { - const highlighted = applyWordAltDiffHighlightsToFile(file, { - additionLines: makePlainHighlightedLines(file.additionLines), - deletionLines: makePlainHighlightedLines(file.deletionLines), - }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount: highlighted.additionLines.length + highlighted.deletionLines.length, - durationMs: Date.now() - startedAt, - }); - return highlighted; - } - - const additionLines: Array> = []; - const deletionLines: Array> = []; - let highlightedLineCount = 0; - - await highlightPatchLinesInChunks({ - lines: file.additionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - additionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - await waitForNextFrame(); - await highlightPatchLinesInChunks({ - lines: file.deletionLines, - language, - theme: shikiTheme, - onChunk: (startIndex, tokens) => { - tokens.forEach((lineTokens, index) => { - deletionLines[startIndex + index] = lineTokens; - }); - highlightedLineCount += tokens.length; - }, - }); - - const highlighted = applyWordAltDiffHighlightsToFile(file, { additionLines, deletionLines }); - if (!REVIEW_HIGHLIGHTER_DISABLE_RESULT_CACHE) { - storeResolvedHighlightedFile(cacheKey, highlighted); - } - onProgress({ - highlightedFile: highlighted, - complete: true, - highlightedLineCount, - }); - logReviewHighlighterDiagnostic("file stream highlight complete", { - fileId: file.id, - filePath: file.path, - theme, - language, - highlightedAdditionLineCount: highlighted.additionLines.length, - highlightedDeletionLineCount: highlighted.deletionLines.length, - highlightedLineCount, - durationMs: Date.now() - startedAt, - }); - return highlighted; -} - export async function highlightReviewSelectedLines(input: { readonly filePath: string; readonly lines: ReadonlyArray; From 0acf05f4f561b873853587f8629ca691b18a613a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:54:25 -0700 Subject: [PATCH 105/320] test(server): cover CLI runner detection through command suggestions (#10066) --- apps/server/src/cli/invocation.test.ts | 81 +++++++++++++++----------- apps/server/src/cli/invocation.ts | 4 +- 2 files changed, 49 insertions(+), 36 deletions(-) diff --git a/apps/server/src/cli/invocation.test.ts b/apps/server/src/cli/invocation.test.ts index c01a2caa49b5..370a8977fc4c 100644 --- a/apps/server/src/cli/invocation.test.ts +++ b/apps/server/src/cli/invocation.test.ts @@ -1,49 +1,62 @@ import { assert, it } from "@effect/vitest"; -import { detectCliRunner, formatCliCommand, suggestedPackageSpec } from "./invocation.ts"; +import { formatCliCommand } from "./invocation.ts"; -it("detects package runners from their cache entry paths", () => { - assert.equal(detectCliRunner("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"), "npx"); - assert.equal( - detectCliRunner( +it("formats package runner commands from their cache entry paths", () => { + for (const [entryPath, expected] of [ + ["/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", "npx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\npm-cache\\_npx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "npx", - ); - assert.equal( - detectCliRunner("/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner("/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs"), - "pnpm dlx", - ); - assert.equal( - detectCliRunner( + "npx t3 serve", + ], + ["/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", "pnpm dlx t3 serve"], + [ + "/home/theo/.local/share/pnpm/.pnpm/dlx/abc/node_modules/t3/dist/bin.mjs", + "pnpm dlx t3 serve", + ], + [ "C:\\Users\\theo\\AppData\\Local\\pnpm-cache\\dlx\\abc\\node_modules\\t3\\dist\\bin.mjs", - ), - "pnpm dlx", - ); - assert.equal(detectCliRunner("/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs"), "bunx"); - assert.equal(detectCliRunner("/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs"), "bunx"); - assert.equal( - detectCliRunner( + "pnpm dlx t3 serve", + ], + ["/home/theo/.bun/install/cache/t3@0.0.31/dist/bin.mjs", "bunx t3 serve"], + ["/tmp/bunx-1000-t3@latest/node_modules/t3/dist/bin.mjs", "bunx t3 serve"], + [ "C:\\Users\\theo\\AppData\\Local\\Temp\\bunx-0-t3@latest\\node_modules\\t3\\dist\\bin.mjs", - ), - "bunx", - ); + "bunx t3 serve", + ], + ] as const) { + assert.equal(formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), expected); + } }); it("treats stable installs as direct invocations", () => { - assert.isNull(detectCliRunner("/usr/local/lib/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/Code/work/t3code/apps/server/dist/bin.mjs")); - assert.isNull(detectCliRunner("/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs")); - assert.isNull(detectCliRunner("")); + for (const entryPath of [ + "/usr/local/lib/node_modules/t3/dist/bin.mjs", + "/home/theo/Code/work/t3code/apps/server/dist/bin.mjs", + "/home/theo/.t3/runtime/0.0.31/node_modules/t3/dist/bin.mjs", + "", + ]) { + assert.equal( + formatCliCommand({ subcommand: "serve", entryPath, version: "0.0.31" }), + "t3 serve", + ); + } }); it("re-suggests the nightly channel only for nightly builds", () => { - assert.equal(suggestedPackageSpec("0.0.31-nightly.20260729"), "t3@nightly"); - assert.equal(suggestedPackageSpec("0.0.31"), "t3"); + for (const [version, expected] of [ + ["0.0.31-nightly.20260729", "npx t3@nightly serve"], + ["0.0.31", "npx t3 serve"], + ] as const) { + assert.equal( + formatCliCommand({ + subcommand: "serve", + entryPath: "/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs", + version, + }), + expected, + ); + } }); it("formats serve suggestions to match the launching command", () => { diff --git a/apps/server/src/cli/invocation.ts b/apps/server/src/cli/invocation.ts index e1b03552948d..55f5b66ad9dd 100644 --- a/apps/server/src/cli/invocation.ts +++ b/apps/server/src/cli/invocation.ts @@ -18,7 +18,7 @@ export type CliRunner = "npx" | "pnpm dlx" | "bunx"; * Global installs and repo checkouts match none of these and return null. * Detection is best-effort; callers must fail closed to a plain `t3` command. */ -export function detectCliRunner(entryPath: string): CliRunner | null { +function detectCliRunner(entryPath: string): CliRunner | null { const path = entryPath.replaceAll("\\", "/"); if (path.includes("/_npx/")) { return "npx"; @@ -42,7 +42,7 @@ export function detectCliRunner(entryPath: string): CliRunner | null { * from the running version: nightly builds re-suggest the nightly channel, * anything else suggests the bare package. */ -export function suggestedPackageSpec(version: string): string { +function suggestedPackageSpec(version: string): string { return version.includes("-nightly.") ? "t3@nightly" : "t3"; } From ee150e178be5e0484a588a8fe7ad98fd6b858973 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:54:36 -0700 Subject: [PATCH 106/320] refactor(mobile): remove unused cloud relay URL normalizer (#10068) --- apps/mobile/src/features/cloud/linkEnvironment.test.ts | 8 -------- apps/mobile/src/features/cloud/linkEnvironment.ts | 8 -------- 2 files changed, 16 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index 42aa8ffebb61..b1280db91615 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -17,7 +17,6 @@ import { connectCloudEnvironment, listCloudEnvironments, listCloudEnvironmentsWithStatus, - normalizeRelayBaseUrl, refreshCloudEnvironmentConnection, } from "./linkEnvironment"; @@ -195,13 +194,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("normalizes configured relay base URLs before building DPoP-bound requests", () => { - expect(normalizeRelayBaseUrl(" https://relay.example.test/// ")).toBe( - "https://relay.example.test", - ); - expect(normalizeRelayBaseUrl(" ")).toBeNull(); - }); - it("makes linked environments visible while their status is still loading", () => { expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ { diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index c2033117f69d..be63cd5877bd 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -43,14 +43,6 @@ const RELAY_STATUS_AND_CONNECT_SCOPES = [ RelayEnvironmentConnectScope, ] satisfies ReadonlyArray; -export function normalizeRelayBaseUrl(value: string | null | undefined): string | null { - const trimmed = value?.trim(); - if (!trimmed) { - return null; - } - return trimmed.replace(/\/+$/g, ""); -} - function readRelayUrl(): string | null { return resolveCloudPublicConfig().relay.url; } From 009c13fade9dde2fe7eed068c72c579172f12e60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:54:47 -0700 Subject: [PATCH 107/320] refactor(web): test live keybinding resolvers directly (#10069) --- apps/web/src/keybindings.test.ts | 57 ++++++++++++++++++-------------- apps/web/src/keybindings.ts | 24 -------------- 2 files changed, 33 insertions(+), 48 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 8cbc45966529..6390269ea0d9 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -8,8 +8,6 @@ import { } from "@t3tools/contracts"; import { formatShortcutLabel, - isChatNewShortcut, - isChatNewLocalShortcut, isDiffToggleShortcut, modelPickerJumpCommandForIndex, modelPickerJumpIndexFromCommand, @@ -22,7 +20,7 @@ import { isTerminalToggleShortcut, resolveShortcutCommand, shouldShowModelPickerJumpHints, - shouldShowThreadJumpHints, + shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, @@ -498,17 +496,21 @@ describe("thread navigation helpers", () => { it("shows jump hints only when configured modifiers match", () => { assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), ); assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - }), + shouldShowThreadJumpHintsForModifiers( + event({ metaKey: true, shiftKey: true }), + DEFAULT_BINDINGS, + { + platform: "MacIntel", + }, + ), ); assert.isTrue( - shouldShowThreadJumpHints(event({ ctrlKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ ctrlKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), ); @@ -516,13 +518,13 @@ describe("thread navigation helpers", () => { it("never shows jump hints while the terminal is focused, even with an unrestricted binding", () => { assert.isFalse( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: true }, }), ); assert.isTrue( - shouldShowThreadJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { + shouldShowThreadJumpHintsForModifiers(event({ metaKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", context: { terminalFocus: false }, }), @@ -558,28 +560,32 @@ describe("model picker navigation helpers", () => { describe("chat/editor shortcuts", () => { it("matches chat.new shortcut", () => { - assert.isTrue( - isChatNewShortcut(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.new", ); - assert.isTrue( - isChatNewShortcut(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "o", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.new", ); }); it("matches chat.newLocal shortcut", () => { - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "MacIntel", }), + "chat.newLocal", ); - assert.isTrue( - isChatNewLocalShortcut(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, { platform: "Linux", }), + "chat.newLocal", ); }); @@ -699,11 +705,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isFalse( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: true }, }), + "terminal.new", ); assert.isFalse( isTerminalNewShortcut(event({ key: "n", metaKey: true }), keybindings, { @@ -711,11 +718,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: false }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", metaKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", metaKey: true }), keybindings, { platform: "MacIntel", context: { terminalFocus: false }, }), + "chat.new", ); }); @@ -735,11 +743,12 @@ describe("cross-command precedence", () => { context: { terminalFocus: true }, }), ); - assert.isTrue( - isChatNewShortcut(event({ key: "n", ctrlKey: true }), keybindings, { + assert.strictEqual( + resolveShortcutCommand(event({ key: "n", ctrlKey: true }), keybindings, { platform: "Linux", context: { terminalFocus: true }, }), + "chat.new", ); }); }); diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 2a8d385cb42b..62f107a4daad 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -288,14 +288,6 @@ export function threadTraversalDirectionFromCommand( return null; } -export function shouldShowThreadJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowThreadJumpHintsForModifiers(event, keybindings, options); -} - export function shouldShowThreadJumpHintsForModifiers( modifiers: ShortcutModifierStateLike, keybindings: ResolvedKeybindingsConfig, @@ -419,22 +411,6 @@ export function isPreviewRefreshShortcut( return matchesCommandShortcut(event, keybindings, "preview.refresh", options); } -export function isChatNewShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.new", options); -} - -export function isChatNewLocalShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "chat.newLocal", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, From 25cbcd62d9750d3e8903fd752078e66c7943ecdb Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:55:26 -0700 Subject: [PATCH 108/320] test(server): cover Grok skill parsing through discovery (#10070) --- .../src/provider/Drivers/GrokSkills.test.ts | 241 ++++++++++-------- .../server/src/provider/Drivers/GrokSkills.ts | 4 - 2 files changed, 136 insertions(+), 109 deletions(-) diff --git a/apps/server/src/provider/Drivers/GrokSkills.test.ts b/apps/server/src/provider/Drivers/GrokSkills.test.ts index 13415bc35de3..ce8a31b51985 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.test.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.test.ts @@ -1,141 +1,172 @@ import { describe, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; -import * as Layer from "effect/Layer"; import * as Sink from "effect/Sink"; import * as Stream from "effect/Stream"; import { ChildProcessSpawner } from "effect/unstable/process"; -import { discoverGrokSkills, parseGrokInspectSkills } from "./GrokSkills.ts"; +import { discoverGrokSkills } from "./GrokSkills.ts"; const inspectPayload = (skills: ReadonlyArray) => JSON.stringify({ skills }); -describe("parseGrokInspectSkills", () => { - it("maps inspect entries onto provider skills, sorted by name", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { - name: "writing-docs", - description: "Write user docs.", - source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, - userInvocable: true, - }, +const makeInspectSpawner = (stdout: string, exitCode = 0, spawnCwds?: Array) => + ChildProcessSpawner.make((command) => { + spawnCwds?.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + +describe("discoverGrokSkills", () => { + it.effect("maps inspect entries onto provider skills, sorted by name", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + + expect(skills).toEqual([ { name: "deploy", description: "Deploy the app.", - source: { - type: "plugin", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - }, - userInvocable: true, + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + scope: "plugin", + enabled: true, }, - ]), - ); + { + name: "writing-docs", + description: "Write user docs.", + path: "/home/dev/.grok/skills/writing-docs/SKILL.md", + scope: "user", + enabled: true, + }, + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "writing-docs", + description: "Write user docs.", + source: { type: "user", path: "/home/dev/.grok/skills/writing-docs/SKILL.md" }, + userInvocable: true, + }, + { + name: "deploy", + description: "Deploy the app.", + source: { + type: "plugin", + path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", + }, + userInvocable: true, + }, + ]), + ), + ), + ), + ); - expect(skills).toEqual([ - { - name: "deploy", - description: "Deploy the app.", - path: "/home/dev/.grok/installed-plugins/pkg/plug/skills/deploy/SKILL.md", - scope: "plugin", - enabled: true, - }, - { - name: "writing-docs", - description: "Write user docs.", - path: "/home/dev/.grok/skills/writing-docs/SKILL.md", - scope: "user", - enabled: true, - }, - ]); - }); + it.effect("disables skills the CLI marks as not user-invocable", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); - it("disables skills the CLI marks as not user-invocable", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ + expect(skills).toEqual([ { name: "internal-helper", - source: { type: "bundled", path: "/opt/grok/bundled/skills/internal-helper/SKILL.md" }, - userInvocable: false, + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + scope: "bundled", + enabled: false, }, - ]), - ); - - expect(skills).toEqual([ - { - name: "internal-helper", - path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", - scope: "bundled", - enabled: false, - }, - ]); - }); - - it("skips entries without a name or a filesystem path", () => { - const skills = parseGrokInspectSkills( - inspectPayload([ - { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, - { name: "no-path", source: { type: "user" } }, - { name: "no-source" }, - "not-an-object", - { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, - ]), - ); + ]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { + name: "internal-helper", + source: { + type: "bundled", + path: "/opt/grok/bundled/skills/internal-helper/SKILL.md", + }, + userInvocable: false, + }, + ]), + ), + ), + ), + ); - expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - }); + it.effect("skips entries without a name or a filesystem path", () => + Effect.gen(function* () { + const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}); + expect(skills.map((skill) => skill.name)).toEqual(["kept"]); + }).pipe( + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner( + inspectPayload([ + { name: " ", source: { type: "user", path: "/tmp/skills/a/SKILL.md" } }, + { name: "no-path", source: { type: "user" } }, + { name: "no-source" }, + "not-an-object", + { name: "kept", source: { type: "project", path: "/repo/.grok/skills/kept/SKILL.md" } }, + ]), + ), + ), + ), + ); - it("returns an empty list for malformed or unexpected output", () => { - expect(parseGrokInspectSkills("not json")).toEqual([]); - expect(parseGrokInspectSkills("null")).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({ skills: "nope" }))).toEqual([]); - expect(parseGrokInspectSkills(JSON.stringify({}))).toEqual([]); - }); -}); + it.effect("rejects malformed or unexpected output as a decode failure", () => + Effect.gen(function* () { + for (const stdout of ["not json", "null", '{"skills":"nope"}', "{}"]) { + const error = yield* discoverGrokSkills({ binaryPath: "grok" }, {}).pipe( + Effect.flip, + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout), + ), + ); + expect(error).toMatchObject({ _tag: "GrokSkillsProbeError", stage: "decode" }); + } + }), + ); -describe("discoverGrokSkills", () => { it.effect("spawns in the configured cwd and rejects a failed probe", () => { const spawnCwds: Array = []; - let exitCode = 0; - const spawner = ChildProcessSpawner.make((command) => { - spawnCwds.push(command._tag === "StandardCommand" ? command.options.cwd : undefined); - return Effect.succeed( - ChildProcessSpawner.makeHandle({ - pid: ChildProcessSpawner.ProcessId(1), - exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)), - isRunning: Effect.succeed(false), - kill: () => Effect.void, - unref: Effect.succeed(Effect.void), - stdin: Sink.drain, - stdout: Stream.encodeText( - Stream.make( - inspectPayload([ - { - name: "kept", - source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, - }, - ]), - ), - ), - stderr: Stream.empty, - all: Stream.empty, - getInputFd: () => Sink.drain, - getOutputFd: () => Stream.empty, - }), - ); - }); + const stdout = inspectPayload([ + { + name: "kept", + source: { type: "project", path: "/workspaces/demo/.grok/skills/kept/SKILL.md" }, + }, + ]); return Effect.gen(function* () { const skills = yield* discoverGrokSkills({ binaryPath: "grok" }, {}, "/workspaces/demo").pipe( - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 0, spawnCwds), + ), ); expect(spawnCwds).toEqual(["/workspaces/demo"]); expect(skills.map((skill) => skill.name)).toEqual(["kept"]); - exitCode = 1; const failed = yield* discoverGrokSkills({ binaryPath: "grok" }).pipe( Effect.result, - Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)), + Effect.provideService( + ChildProcessSpawner.ChildProcessSpawner, + makeInspectSpawner(stdout, 1), + ), ); expect(failed._tag).toBe("Failure"); }); diff --git a/apps/server/src/provider/Drivers/GrokSkills.ts b/apps/server/src/provider/Drivers/GrokSkills.ts index b7962205d346..53a20049ad5d 100644 --- a/apps/server/src/provider/Drivers/GrokSkills.ts +++ b/apps/server/src/provider/Drivers/GrokSkills.ts @@ -91,10 +91,6 @@ function decodeGrokInspectSkills(stdout: string): ReadonlyArray left.name.localeCompare(right.name)); } -export function parseGrokInspectSkills(stdout: string): ReadonlyArray { - return decodeGrokInspectSkills(stdout) ?? []; -} - /** * Run `grok inspect --json` and map the reported catalog onto provider * skills. Callers that need best-effort discovery can recover this effect to From 38812c101e562c4f8d9bea7b1a592a4b02954a39 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:56:35 -0700 Subject: [PATCH 109/320] test(web): remove mocked diff view prop snapshot (#10073) --- .../diffs/StyledDiffCodeView.test.tsx | 48 ------------------- 1 file changed, 48 deletions(-) diff --git a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx index 6a746b40f9af..ee249888a415 100644 --- a/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx +++ b/apps/web/src/components/diffs/StyledDiffCodeView.test.tsx @@ -18,13 +18,10 @@ import { useState, type Ref, } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ - codeViewClassName: null as string | null, - codeViewOptions: null as Record | null, workers: [] as NodeWorkerThreads.Worker[], terminations: [] as Promise[], requests: [] as WorkerRequest[], @@ -102,8 +99,6 @@ vi.mock("@pierre/diffs/worker/worker.js?worker", async () => { vi.mock("@pierre/diffs/react", async (importOriginal) => ({ ...(await importOriginal()), CodeView: (props: CodeViewProps) => { - testState.codeViewClassName = props.className ?? null; - testState.codeViewOptions = props.options ? { ...props.options } : null; return props.items?.map((item) => item.type === "file" ? : null, ); @@ -158,49 +153,6 @@ function renderViews(count: number) { ); } -describe("StyledDiffCodeView", () => { - beforeEach(() => { - testState.codeViewClassName = null; - testState.codeViewOptions = null; - }); - - it("always pairs the shared diff styling with its virtualized geometry", () => { - const loadDiffFiles = vi.fn(async () => ({ - oldFile: { name: "before.ts", contents: "before\n" }, - newFile: { name: "after.ts", contents: "after\n" }, - })); - renderToStaticMarkup( - , - ); - - expect(testState.codeViewClassName).toBe( - "diff-render-surface [--code-background:var(--background)] outline-none min-h-0", - ); - expect(testState.codeViewOptions).toMatchObject({ - theme: "pierre-dark", - stickyHeaders: true, - loadDiffFiles, - itemMetrics: { - diffHeaderHeight: 32, - hunkSeparatorHeight: 24, - paddingTop: 0, - paddingBottom: 8, - }, - layout: { paddingTop: 0, paddingBottom: 0, gap: 0 }, - }); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining("[data-unmodified-lines]::before"), - ); - expect(testState.codeViewOptions?.unsafeCSS).toEqual( - expect.stringContaining(")[data-expand-index]\n [data-unmodified-lines]"), - ); - }); -}); - describe("code-view worker lifecycle", () => { let renderer: ReactTestRenderer | undefined; From 19ea3c5beb4cd93dd19f51f3e7c6d760357b41df Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:56:48 -0700 Subject: [PATCH 110/320] test(web): remove mocked annotation options snapshot (#10074) --- .../diffs/AnnotatableCodeView.test.tsx | 65 ------------------- 1 file changed, 65 deletions(-) delete mode 100644 apps/web/src/components/diffs/AnnotatableCodeView.test.tsx diff --git a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx b/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx deleted file mode 100644 index 81cd5625e62b..000000000000 --- a/apps/web/src/components/diffs/AnnotatableCodeView.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import type { ReactNode } from "react"; -import { renderToStaticMarkup } from "react-dom/server"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; - -const testState = vi.hoisted(() => ({ - codeViewOptions: null as Record | null, -})); - -vi.mock("@pierre/diffs/react", () => ({ - CodeView: (props: { options: Record }) => { - testState.codeViewOptions = props.options; - return null; - }, -})); - -vi.mock("../DiffWorkerPoolProvider", () => ({ - DiffWorkerPoolProvider: ({ children }: { children?: ReactNode }) => children, -})); - -vi.mock("~/composerDraftStore", () => ({ - useComposerDraftStore: (selector: (store: Record) => unknown) => - selector({ - addReviewComment: vi.fn(), - removeReviewComment: vi.fn(), - getComposerDraft: () => undefined, - }), -})); - -vi.mock("./DiffCommentAnnotation", () => ({ - DiffCommentAnnotation: () => null, -})); - -vi.mock("../files/fileCommentAnnotations", () => ({ - nextFileCommentId: () => "comment-test", -})); - -import { AnnotatableCodeView } from "./AnnotatableCodeView"; - -describe("AnnotatableCodeView", () => { - beforeEach(() => { - testState.codeViewOptions = null; - }); - - it("opens comments from Pierre's gutter action without ending line selection", () => { - renderToStaticMarkup( - null} - renderHeaderFilenameSuffix={() => null} - />, - ); - - expect(testState.codeViewOptions).toMatchObject({ - enableGutterUtility: true, - enableLineSelection: true, - onGutterUtilityClick: expect.any(Function), - }); - expect(testState.codeViewOptions).not.toHaveProperty("onLineSelectionEnd"); - }); -}); From 688e5948046e14be7f2bfd48d1c91b7e94d79267 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 01:57:00 -0700 Subject: [PATCH 111/320] refactor(web): keep pending action labels private (#10075) --- .../chat/ComposerPrimaryActions.test.tsx | 92 +------------------ .../chat/ComposerPrimaryActions.tsx | 2 +- 2 files changed, 2 insertions(+), 92 deletions(-) diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx index 92f24c833db8..45ef93568cf6 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx @@ -15,7 +15,7 @@ vi.mock("../SidebarStageBackdrop", () => ({ useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null), })); -import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions"; +import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; function renderPendingActions(isRunning: boolean) { return renderToStaticMarkup( @@ -92,96 +92,6 @@ afterEach(() => { stageArtworkState.variant = null; }); -describe("formatPendingPrimaryActionLabel", () => { - it("returns 'Submitting...' while responding", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: true, - questionIndex: 0, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submitting...' while responding regardless of other flags", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: true, - questionIndex: 3, - }), - ).toBe("Submitting..."); - }); - - it("returns 'Submit' in compact mode on the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit"); - }); - - it("returns 'Next' in compact mode when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: true, - isLastQuestion: false, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Next"); - }); - - it("returns 'Next question' when not the last question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: false, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Next question"); - }); - - it("returns singular 'Submit answer' on the last question when it is the only question", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 0, - }), - ).toBe("Submit answer"); - }); - - it("returns plural 'Submit answers' on the last question when there are multiple questions", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 1, - }), - ).toBe("Submit answers"); - }); - - it("returns plural 'Submit answers' for higher question indices", () => { - expect( - formatPendingPrimaryActionLabel({ - compact: false, - isLastQuestion: true, - isResponding: false, - questionIndex: 5, - }), - ).toBe("Submit answers"); - }); -}); - describe("ComposerPrimaryActions", () => { it("disables and labels the send button while feedback is uploading", () => { const markup = renderSendButton("Sending feedback"); diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index 46323aa2b09a..91c54b75ed03 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -37,7 +37,7 @@ interface ComposerPrimaryActionsProps { onImplementPlanInNewThread: () => void; } -export const formatPendingPrimaryActionLabel = (input: { +const formatPendingPrimaryActionLabel = (input: { compact: boolean; isLastQuestion: boolean; isResponding: boolean; From b92a8122876458a67cc225f75c1d31bd3e33dc30 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 02:02:12 -0700 Subject: [PATCH 112/320] refactor(mobile): remove unused cloud pending-status mapper (#10071) --- .../mobile/src/features/cloud/linkEnvironment.test.ts | 11 ----------- apps/mobile/src/features/cloud/linkEnvironment.ts | 10 ---------- 2 files changed, 21 deletions(-) diff --git a/apps/mobile/src/features/cloud/linkEnvironment.test.ts b/apps/mobile/src/features/cloud/linkEnvironment.test.ts index b1280db91615..feadf6c81893 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.test.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.test.ts @@ -11,7 +11,6 @@ import { MobilePreferencesStore } from "../../persistence/mobile-preferences"; import { MobileStorage } from "../../persistence/mobile-storage"; import { - cloudEnvironmentsPendingStatus, linkEnvironmentToCloud, linkEnvironmentToCloudWithPreference, connectCloudEnvironment, @@ -194,16 +193,6 @@ describe("mobile cloud link environment client", () => { loadPreferences.mockClear(); }); - it("makes linked environments visible while their status is still loading", () => { - expect(cloudEnvironmentsPendingStatus([listedEnvironment("env-1")])).toMatchObject([ - { - environment: { environmentId: "env-1", label: "Desktop" }, - status: null, - statusError: "Checking status...", - }, - ]); - }); - it.effect("decodes relay environment list responses before returning records", () => Effect.gen(function* () { vi.stubGlobal( diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index be63cd5877bd..b8dc8f878e0c 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -397,16 +397,6 @@ export function getCloudEnvironmentStatus(input: { }); } -export function cloudEnvironmentsPendingStatus( - environments: ReadonlyArray, -): ReadonlyArray { - return environments.map((environment) => ({ - environment, - status: null, - statusError: "Checking status...", - })); -} - export function loadCloudEnvironmentStatuses(input: { readonly clerkToken: string; readonly environments: ReadonlyArray; From ab0933a41ae3a69632ea68809700a040bde26c22 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 02:04:27 -0700 Subject: [PATCH 113/320] refactor(web): remove unused model picker hint helpers (#10072) --- apps/web/src/keybindings.test.ts | 16 ---------------- apps/web/src/keybindings.ts | 26 -------------------------- 2 files changed, 42 deletions(-) diff --git a/apps/web/src/keybindings.test.ts b/apps/web/src/keybindings.test.ts index 6390269ea0d9..df005571193e 100644 --- a/apps/web/src/keybindings.test.ts +++ b/apps/web/src/keybindings.test.ts @@ -19,7 +19,6 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, resolveShortcutCommand, - shouldShowModelPickerJumpHints, shouldShowThreadJumpHintsForModifiers, shortcutLabelForCommand, terminalDeleteShortcutData, @@ -541,21 +540,6 @@ describe("model picker navigation helpers", () => { assert.strictEqual(modelPickerJumpIndexFromCommand("modelPicker.jump.3"), 2); assert.isNull(modelPickerJumpIndexFromCommand("thread.jump.1")); }); - - it("shows jump hints only while the model picker context is active", () => { - assert.isFalse( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: false }, - }), - ); - assert.isTrue( - shouldShowModelPickerJumpHints(event({ metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { modelPickerOpen: true }, - }), - ); - }); }); describe("chat/editor shortcuts", () => { diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 62f107a4daad..1b4fa072d5a1 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -329,32 +329,6 @@ export function modelPickerJumpIndexFromCommand(command: string): number | null return index === -1 ? null : index; } -export function shouldShowModelPickerJumpHints( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return shouldShowModelPickerJumpHintsForModifiers(event, keybindings, options); -} - -export function shouldShowModelPickerJumpHintsForModifiers( - modifiers: ShortcutModifierStateLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - const platform = resolvePlatform(options); - - for (const command of MODEL_PICKER_JUMP_KEYBINDING_COMMANDS) { - const shortcut = findEffectiveShortcutForCommand(keybindings, command, options); - if (!shortcut) continue; - if (matchesShortcutModifiers(modifiers, shortcut, platform)) { - return true; - } - } - - return false; -} - export function isTerminalToggleShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, From c843c19294bcea9a4f5cf19632b135459a16d214 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Sat, 5 Sep 2026 04:12:01 -0500 Subject: [PATCH 114/320] fix(server): resume checkpointing after git init (#10078) --- .../Layers/CheckpointReactor.test.ts | 184 ++++++++++++++---- .../orchestration/Layers/CheckpointReactor.ts | 75 +++---- 2 files changed, 182 insertions(+), 77 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 390e61138f08..cb1725882fba 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -292,6 +292,7 @@ describe("CheckpointReactor", () => { async function createHarness(options?: { readonly hasSession?: boolean; readonly seedFilesystemCheckpoints?: boolean; + readonly initializeGit?: boolean; readonly projectWorkspaceRoot?: string; readonly threadWorktreePath?: string | null; readonly threadBranch?: string | null; @@ -303,6 +304,9 @@ describe("CheckpointReactor", () => { readonly pullRequestRefreshCalls?: Array; }) { const cwd = createGitRepository(); + if (options?.initializeGit === false) { + NodeFS.rmSync(NodePath.join(cwd, ".git"), { recursive: true }); + } tempDirs.push(cwd); const provider = createProviderServiceHarness( cwd, @@ -1158,52 +1162,148 @@ describe("CheckpointReactor", () => { ).toBe(true); }); - it("appends capture failure activity when turn diff summary cannot be derived", async () => { - const harness = await createHarness({ seedFilesystemCheckpoints: false }); - const createdAt = "2026-01-01T00:00:00.000Z"; - - await Effect.runPromise( - harness.engine.dispatch({ - type: "thread.session.set", - commandId: CommandId.make("cmd-session-set-missing-baseline-diff"), + effectIt.effect("captures a checkpoint without a summary when the baseline is missing", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ seedFilesystemCheckpoints: false }), + ); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-missing-baseline"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", threadId: ThreadId.make("thread-1"), - session: { - threadId: ThreadId.make("thread-1"), - status: "ready", - providerName: "codex", - runtimeMode: "approval-required", - activeTurnId: null, - lastError: null, - updatedAt: createdAt, - }, - createdAt, - }), - ); - - harness.provider.emit({ - type: "turn.completed", - eventId: EventId.make("evt-turn-completed-missing-baseline"), - provider: ProviderDriverKind.make("codex"), + turnId: asTurnId("turn-missing-baseline"), + payload: { state: "completed" }, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[0]).toMatchObject({ + status: "ready", + checkpointTurnCount: 1, + files: [], + }); + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)), + ).toBe(true); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); - createdAt: "2026-01-01T00:00:00.000Z", - threadId: ThreadId.make("thread-1"), - turnId: asTurnId("turn-missing-baseline"), - payload: { state: "completed" }, - }); + effectIt.effect.each([ + { timing: "between turns", commit: false }, + { timing: "between turns", commit: true }, + { timing: "during a turn", commit: false }, + { timing: "during a turn", commit: true }, + ])("resumes checkpointing after git init $timing (commit: $commit)", ({ timing, commit }) => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => + createHarness({ initializeGit: false, seedFilesystemCheckpoints: false }), + ); + const threadId = ThreadId.make("thread-1"); + const createdAt = "2026-01-01T00:00:00.000Z"; + const emit = (type: "turn.started" | "turn.completed", turn: number) => + harness.provider.emit({ + type, + eventId: EventId.make(`${type}-${turn}`), + provider: ProviderDriverKind.make("codex"), + createdAt, + threadId, + turnId: asTurnId(`turn-${turn}`), + ...(type === "turn.completed" ? { payload: { state: "completed" } } : {}), + }); + emit("turn.started", 1); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "before git\n"); + emit("turn.completed", 1); + yield* Effect.promise(harness.drain); + expect((yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints).toEqual([]); - await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed"); - const thread = await waitForThread( - harness.readModel, - (entry) => - entry.checkpoints.length === 1 && - entry.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ); + if (timing === "during a turn") { + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + runGit(harness.cwd, ["init", "--initial-branch=main"]); + if (commit) { + runGit(harness.cwd, ["add", "."]); + runGit(harness.cwd, [ + "-c", + "user.name=Test", + "-c", + "user.email=test@example.com", + "commit", + "-m", + "Initial", + ]); + } + if (timing === "between turns") { + // Exercise the domain entry point as well as the provider turn-start event. + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-after-git-init"), + threadId, + message: { + messageId: MessageId.make("message-after-git-init"), + role: "user", + text: "continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.baseline.captured", + checkpointTurnCount: 0, + }); + emit("turn.started", 2); + yield* Effect.promise(harness.drain); + } + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "after git\n"); + emit("turn.completed", 2); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 1, + }); + expect(yield* harness.nextReceipt).toMatchObject({ type: "turn.processing.quiesced" }); + yield* Effect.promise(harness.drain); + const firstCheckpoint = (yield* Effect.promise(harness.readModel)).threads[0]?.checkpoints[0]; + expect(firstCheckpoint?.files).toEqual( + timing === "between turns" + ? [{ path: "README.md", kind: "modified", additions: 1, deletions: 1 }] + : [], + ); + expect( + gitShowFileAtRef(harness.cwd, checkpointRefForThreadTurn(threadId, 1), "README.md"), + ).toBe("after git\n"); + expect(gitRefExists(harness.cwd, checkpointRefForThreadTurn(threadId, 0))).toBe( + timing === "between turns", + ); - expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1); - expect( - thread.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), - ).toBe(true); - }); + emit("turn.started", 3); + yield* Effect.promise(harness.drain); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "next turn\n"); + emit("turn.completed", 3); + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + checkpointTurnCount: 2, + }); + yield* Effect.promise(harness.drain); + const thread = (yield* Effect.promise(harness.readModel)).threads[0]; + expect(thread?.checkpoints[1]?.files).toEqual([ + { path: "README.md", kind: "modified", additions: 1, deletions: 1 }, + ]); + expect( + thread?.activities.some((activity) => activity.kind === "checkpoint.capture.failed"), + ).toBe(false); + }), + ); it("captures pre-turn baseline from project workspace root when thread worktree is unset", async () => { const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index f155a6ae365c..de1cb29c5cd9 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -258,41 +258,46 @@ const make = Effect.gen(function* () { // reflects files created or deleted during this turn. yield* workspaceEntries.refresh(input.cwd); - const files = yield* checkpointStore - .diffCheckpoints({ - cwd: input.cwd, - fromCheckpointRef, - toCheckpointRef: targetCheckpointRef, - fallbackFromToHead: false, - ignoreWhitespace: false, - format: "numstat", - }) - .pipe( - Effect.map((diff) => - parseTurnDiffFilesFromNumstat(diff).map((file) => ({ - path: file.path, - kind: "modified" as const, - additions: file.additions, - deletions: file.deletions, - })), - ), - Effect.tapError((error) => - appendCaptureFailureActivity({ - threadId: input.threadId, - turnId: input.turnId, - detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, - createdAt: input.createdAt, - }), - ), - Effect.catch((error) => - Effect.logWarning("failed to derive checkpoint file summary", { - threadId: input.threadId, - turnId: input.turnId, - turnCount: input.turnCount, - detail: error.message, - }).pipe(Effect.as([])), - ), - ); + // Git may have been initialized during this turn, leaving no pre-turn + // snapshot. Keep the completion checkpoint for future turns, but do not + // invent a baseline or attempt a diff against a ref that does not exist. + const files = yield* ( + fromCheckpointExists + ? checkpointStore.diffCheckpoints({ + cwd: input.cwd, + fromCheckpointRef, + toCheckpointRef: targetCheckpointRef, + fallbackFromToHead: false, + ignoreWhitespace: false, + format: "numstat", + }) + : Effect.succeed("") + ).pipe( + Effect.map((diff) => + parseTurnDiffFilesFromNumstat(diff).map((file) => ({ + path: file.path, + kind: "modified" as const, + additions: file.additions, + deletions: file.deletions, + })), + ), + Effect.tapError((error) => + appendCaptureFailureActivity({ + threadId: input.threadId, + turnId: input.turnId, + detail: `Checkpoint captured, but turn diff summary is unavailable: ${error.message}`, + createdAt: input.createdAt, + }), + ), + Effect.catch((error) => + Effect.logWarning("failed to derive checkpoint file summary", { + threadId: input.threadId, + turnId: input.turnId, + turnCount: input.turnCount, + detail: error.message, + }).pipe(Effect.as([])), + ), + ); const assistantMessageId = input.assistantMessageId ?? From 09aac71563c66a4f65f6fbe701aa9596cb677767 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 5 Sep 2026 02:22:30 -0700 Subject: [PATCH 115/320] feat(web): first-run welcome wizard with agent setup and project import (#5362) Co-authored-by: Claude Fable 5 --- .../DesktopClientSettings.diagnostics.test.ts | 23 +- .../settings/DesktopClientSettings.test.ts | 92 +- .../src/settings/DesktopClientSettings.ts | 60 +- apps/server/src/auth/RpcAuthorization.test.ts | 9 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/CheckpointReactor.test.ts | 30 + .../orchestration/Layers/CheckpointReactor.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 96 + .../Layers/ProjectionPipeline.ts | 19 +- .../Layers/ProjectionSnapshotQuery.test.ts | 299 +- .../Layers/ProjectionSnapshotQuery.ts | 109 +- .../Services/ProjectionSnapshotQuery.ts | 10 + .../src/orchestration/decider.import.test.ts | 509 +++ apps/server/src/orchestration/decider.ts | 96 +- apps/server/src/orchestration/projector.ts | 20 +- .../Layers/ProjectionThreadMessages.test.ts | 14 +- .../Layers/ProjectionThreadMessages.ts | 1 + .../src/persistence/ProviderSessionRuntime.ts | 151 +- .../src/project/AgentSessionImporter.test.ts | 1213 +++++++ .../src/project/AgentSessionImporter.ts | 297 ++ .../src/project/AgentSessionScanner.test.ts | 3088 +++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 1313 +++++++ .../project/ProjectSetupScriptRunner.test.ts | 1 + .../src/provider/Drivers/ClaudeDriver.ts | 7 +- .../src/provider/Drivers/CodexDriver.ts | 2 + .../src/provider/Layers/CodexAdapter.test.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 1 + .../ProviderInstanceRegistryLive.test.ts | 133 +- .../provider/Layers/ProviderService.test.ts | 1 + .../Layers/ProviderSessionDirectory.test.ts | 266 +- .../Layers/ProviderSessionDirectory.ts | 55 +- .../Layers/ProviderSessionReaper.test.ts | 1 + .../ProviderInstanceEnvironment.test.ts | 49 +- .../provider/ProviderInstanceEnvironment.ts | 8 +- .../Services/ProviderSessionDirectory.ts | 12 + .../testFixtures/codexCollabMockPeer.mjs | 8 + .../src/relay/AgentAwarenessRelay.test.ts | 98 +- apps/server/src/relay/AgentAwarenessRelay.ts | 3 + apps/server/src/server.test.ts | 203 ++ .../serverRuntimeStartup.reconcile.test.ts | 11 + apps/server/src/serverRuntimeStartup.test.ts | 89 + apps/server/src/serverRuntimeStartup.ts | 127 +- apps/server/src/serverSettings.test.ts | 37 + apps/server/src/terminal/Manager.test.ts | 412 +++ apps/server/src/terminal/Manager.ts | 261 +- apps/server/src/ws.ts | 41 +- apps/web/src/authBootstrap.test.ts | 57 + .../src/browser/HostedBrowserWebview.test.tsx | 200 ++ apps/web/src/browser/HostedBrowserWebview.tsx | 11 +- apps/web/src/browser/browserDefaults.test.ts | 25 +- apps/web/src/browser/browserDefaults.ts | 1 + .../web/src/browser/browserLinkTarget.test.ts | 28 +- apps/web/src/browser/browserLinkTarget.ts | 1 + apps/web/src/browser/browserRecording.test.ts | 31 + apps/web/src/browser/browserRecording.ts | 10 +- .../src/browser/desktopTabLifetime.test.ts | 35 +- apps/web/src/browser/openFileInPreview.ts | 24 +- apps/web/src/browser/useOpenLink.ts | 13 +- apps/web/src/clientPersistenceStorage.test.ts | 39 +- apps/web/src/clientPersistenceStorage.ts | 7 +- apps/web/src/components/ChatMarkdown.tsx | 17 +- apps/web/src/components/ChatView.tsx | 13 + .../components/ThreadTerminalDrawer.test.ts | 89 +- .../src/components/ThreadTerminalDrawer.tsx | 77 +- .../CloudEnvironmentConnectList.test.tsx | 214 ++ .../cloud/CloudEnvironmentConnectList.tsx | 60 +- .../components/onboarding/FirstRunGate.tsx | 244 ++ .../components/onboarding/WelcomeWizard.tsx | 1478 ++++++++ .../preview/PreviewAutomationHosts.test.tsx | 190 + .../preview/PreviewAutomationHosts.tsx | 10 +- .../src/components/preview/PreviewView.tsx | 16 +- .../preview/addBrowserSurface.test.ts | 3 + .../components/preview/addBrowserSurface.ts | 4 +- .../components/preview/openDiscoveredPort.ts | 4 +- .../preview/openPreviewSession.test.ts | 55 +- .../components/preview/openPreviewSession.ts | 12 +- .../preview/openTerminalLinkInPreview.test.ts | 27 + .../settings/providerStatus.test.ts | 71 + .../src/components/settings/providerStatus.ts | 21 +- apps/web/src/environments/primary/auth.ts | 2 + apps/web/src/hooks/useLocalStorage.test.ts | 23 +- apps/web/src/hooks/useLocalStorage.ts | 40 +- apps/web/src/hooks/useSettings.test.ts | 180 +- apps/web/src/hooks/useSettings.ts | 91 +- apps/web/src/hooks/useTheme.test.ts | 197 ++ apps/web/src/hooks/useTheme.ts | 84 +- apps/web/src/index.css | 40 +- .../web/src/onboarding/firstRun.logic.test.ts | 514 +++ apps/web/src/onboarding/firstRun.logic.ts | 184 + apps/web/src/onboarding/firstRun.ts | 16 + .../onboarding/projectImport.logic.test.ts | 245 ++ .../web/src/onboarding/projectImport.logic.ts | 55 + .../providerReadiness.logic.test.ts | 317 ++ .../src/onboarding/providerReadiness.logic.ts | 99 + .../targetEnvironment.logic.test.ts | 211 ++ .../src/onboarding/targetEnvironment.logic.ts | 49 + apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 74 +- apps/web/src/routes/_chat.index.tsx | 9 +- apps/web/src/routes/welcome.tsx | 45 + apps/web/src/state/agentSessions.ts | 25 + docs/user/welcome-wizard.md | 62 + .../client-runtime/src/rpc/client.test.ts | 76 +- packages/client-runtime/src/rpc/client.ts | 49 +- .../client-runtime/src/state/server.test.ts | 218 +- packages/client-runtime/src/state/server.ts | 152 +- .../src/state/threadReducer.test.ts | 128 + .../client-runtime/src/state/threadReducer.ts | 52 +- packages/contracts/src/agentSessions.ts | 98 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.test.ts | 15 + packages/contracts/src/orchestration.ts | 17 + packages/contracts/src/rpc.ts | 30 + packages/contracts/src/server.ts | 3 + packages/contracts/src/settings.ts | 8 + packages/contracts/src/terminal.test.ts | 43 + packages/contracts/src/terminal.ts | 35 +- packages/shared/package.json | 4 + packages/shared/src/dateTime.test.ts | 92 + packages/shared/src/dateTime.ts | 38 + 122 files changed, 15520 insertions(+), 494 deletions(-) create mode 100644 apps/server/src/orchestration/decider.import.test.ts create mode 100644 apps/server/src/project/AgentSessionImporter.test.ts create mode 100644 apps/server/src/project/AgentSessionImporter.ts create mode 100644 apps/server/src/project/AgentSessionScanner.test.ts create mode 100644 apps/server/src/project/AgentSessionScanner.ts create mode 100644 apps/web/src/browser/HostedBrowserWebview.test.tsx create mode 100644 apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx create mode 100644 apps/web/src/components/onboarding/FirstRunGate.tsx create mode 100644 apps/web/src/components/onboarding/WelcomeWizard.tsx create mode 100644 apps/web/src/components/preview/PreviewAutomationHosts.test.tsx create mode 100644 apps/web/src/components/settings/providerStatus.test.ts create mode 100644 apps/web/src/onboarding/firstRun.logic.test.ts create mode 100644 apps/web/src/onboarding/firstRun.logic.ts create mode 100644 apps/web/src/onboarding/firstRun.ts create mode 100644 apps/web/src/onboarding/projectImport.logic.test.ts create mode 100644 apps/web/src/onboarding/projectImport.logic.ts create mode 100644 apps/web/src/onboarding/providerReadiness.logic.test.ts create mode 100644 apps/web/src/onboarding/providerReadiness.logic.ts create mode 100644 apps/web/src/onboarding/targetEnvironment.logic.test.ts create mode 100644 apps/web/src/onboarding/targetEnvironment.logic.ts create mode 100644 apps/web/src/routes/welcome.tsx create mode 100644 apps/web/src/state/agentSessions.ts create mode 100644 docs/user/welcome-wizard.md create mode 100644 packages/contracts/src/agentSessions.ts create mode 100644 packages/shared/src/dateTime.test.ts create mode 100644 packages/shared/src/dateTime.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts index 5034df44cf70..d2fd166e878c 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.diagnostics.test.ts @@ -54,7 +54,7 @@ const readWithLogs = (fileSystemLayer: Layer.Layer) => { const environment = yield* DesktopEnvironment.DesktopEnvironment; const settings = yield* DesktopClientSettings.DesktopClientSettings; return { - result: yield* settings.get, + result: yield* Effect.result(settings.get), settingsPath: environment.clientSettingsPath, records, }; @@ -73,12 +73,13 @@ describe("DesktopClientSettings diagnostics", () => { Effect.gen(function* () { const result = yield* readWithLogs(FileSystem.layerNoop({})); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Success") return assert.fail("expected a successful read"); + assert.isTrue(Option.isNone(result.result.success)); assert.deepEqual(result.records, []); }), ); - it.effect("logs non-missing filesystem failures with the settings path", () => { + it.effect("reports non-missing filesystem failures and logs the settings path", () => { const permissionError = PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", @@ -93,7 +94,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a read failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.strictEqual(result.result.failure.cause, permissionError); assert.equal(result.records.length, 1); assert.deepEqual(result.records[0]?.message, [ "Could not read desktop client settings.", @@ -103,7 +109,7 @@ describe("DesktopClientSettings diagnostics", () => { }); }); - it.effect("logs malformed settings documents with the settings path", () => + it.effect("reports malformed settings documents and logs the settings path", () => Effect.gen(function* () { const result = yield* readWithLogs( FileSystem.layerNoop({ @@ -111,7 +117,12 @@ describe("DesktopClientSettings diagnostics", () => { }), ); - assert.isTrue(Option.isNone(result.result)); + if (result.result._tag !== "Failure") return assert.fail("expected a decode failure"); + assert.instanceOf( + result.result.failure, + DesktopClientSettings.DesktopClientSettingsReadError, + ); + assert.equal(result.result.failure.operation, "decode-document"); assert.equal(result.records.length, 1); const message = result.records[0]?.message; if (!Array.isArray(message)) { diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 0d9ddc8fde91..9fbacc832a90 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -44,6 +44,7 @@ const clientSettings: ClientSettings = { fontSizeTerminal: 12, fontSmoothing: true, glassOpacity: 80, + onboardingCompletedAt: null, panelAnimationDurationMs: 0, planModeEnabled: false, proactivePanelsEnabled: true, @@ -136,6 +137,59 @@ describe("DesktopClientSettings", () => { ), ); + for (const failure of [ + { label: "permission", reason: "PermissionDenied" }, + { label: "I/O", reason: "Unknown" }, + ] as const) { + it.effect(`preserves saved preferences across ${failure.label} read failures and retries`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + const savedSettings = { + ...clientSettings, + onboardingCompletedAt: "2026-09-05T12:00:00.000Z", + }; + yield* settings.set(savedSettings); + const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath); + const cause = PlatformError.systemError({ + _tag: failure.reason, + module: "FileSystem", + method: "readFileString", + pathOrDescriptor: environment.clientSettingsPath, + }); + let failRead = true; + const retryableSettings = yield* DesktopClientSettings.make.pipe( + Effect.provideService( + FileSystem.FileSystem, + FileSystem.FileSystem.of({ + ...fileSystem, + readFileString: (path) => + Effect.suspend(() => + failRead ? Effect.fail(cause) : fileSystem.readFileString(path), + ), + }), + ), + ); + + const error = yield* retryableSettings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "read-file"); + assert.equal(error.path, environment.clientSettingsPath); + assert.strictEqual(error.cause, cause); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + savedContents, + ); + + failRead = false; + assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings)); + }), + ), + ); + } + it.effect("reports the failed client settings write operation and path", () => withClientSettings( Effect.gen(function* () { @@ -222,17 +276,31 @@ describe("DesktopClientSettings", () => { ), ); - it.effect("treats malformed client settings documents as absent", () => - withClientSettings( - Effect.gen(function* () { - const environment = yield* DesktopEnvironment.DesktopEnvironment; - const fileSystem = yield* FileSystem.FileSystem; - const settings = yield* DesktopClientSettings.DesktopClientSettings; - yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); - yield* fileSystem.writeFileString(environment.clientSettingsPath, "{not-json"); + for (const document of [ + { label: "malformed JSON", contents: "{not-json" }, + { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' }, + { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' }, + ]) { + it.effect(`reports ${document.label} without treating the settings file as absent`, () => + withClientSettings( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true }); + yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents); - assert.isTrue(Option.isNone(yield* settings.get)); - }), - ), - ); + const error = yield* settings.get.pipe(Effect.flip); + assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError); + assert.equal(error.operation, "decode-document"); + assert.equal(error.path, environment.clientSettingsPath); + assert.instanceOf(error.cause, Schema.SchemaError); + assert.equal( + yield* fileSystem.readFileString(environment.clientSettingsPath), + document.contents, + ); + }), + ), + ); + } }); diff --git a/apps/desktop/src/settings/DesktopClientSettings.ts b/apps/desktop/src/settings/DesktopClientSettings.ts index 4ff091e27a27..5eadd27d5454 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.ts @@ -12,25 +12,33 @@ import * as Ref from "effect/Ref"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; -const ClientSettingsDocumentSchema = Schema.Struct({ - settings: ClientSettingsSchema, -}); - const ClientSettingsJson = fromLenientJson(ClientSettingsSchema); -const LegacyClientSettingsDocumentJson = fromLenientJson(ClientSettingsDocumentSchema); -const decodeLegacyClientSettingsDocumentJson = Schema.decodeEffect( - LegacyClientSettingsDocumentJson, +const decodeClientSettingsDocument = Schema.decodeEffect( + fromLenientJson(Schema.Record(Schema.String, Schema.Unknown)), ); -const decodeClientSettingsJsonValue = Schema.decodeEffect(ClientSettingsJson); -const decodeClientSettingsJson = (raw: string): Effect.Effect => - decodeLegacyClientSettingsDocumentJson(raw).pipe( - Effect.map((document) => document.settings), - Effect.catchTags({ - SchemaError: () => decodeClientSettingsJsonValue(raw), - }), +const decodeClientSettingsValue = Schema.decodeUnknownEffect(ClientSettingsSchema); +const decodeClientSettingsJson = Effect.fnUntraced(function* (raw: string) { + const document = yield* decodeClientSettingsDocument(raw); + // Select the shape before validation so invalid legacy settings cannot become defaults. + return yield* decodeClientSettingsValue( + Object.hasOwn(document, "settings") ? document.settings : document, ); +}); const encodeClientSettingsJson = Schema.encodeEffect(ClientSettingsJson); +export class DesktopClientSettingsReadError extends Schema.TaggedErrorClass()( + "DesktopClientSettingsReadError", + { + operation: Schema.Literals(["read-file", "decode-document"]), + path: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop client settings read failed during ${this.operation} at ${this.path}.`; + } +} + const DesktopClientSettingsWriteOperation = Schema.Literals([ "create-temporary-file-name", "encode-document", @@ -55,7 +63,7 @@ export class DesktopClientSettingsWriteError extends Schema.TaggedErrorClass>; + readonly get: Effect.Effect, DesktopClientSettingsReadError>; readonly set: ( settings: ClientSettings, ) => Effect.Effect; @@ -65,7 +73,7 @@ export class DesktopClientSettings extends Context.Service< const readClientSettings = ( fileSystem: FileSystem.FileSystem, settingsPath: string, -): Effect.Effect> => +): Effect.Effect, DesktopClientSettingsReadError> => fileSystem.readFileString(settingsPath).pipe( Effect.map(Option.some), Effect.catchTags({ @@ -74,7 +82,15 @@ const readClientSettings = ( ? Effect.succeed(Option.none()) : Effect.logWarning("Could not read desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "read-file", + path: settingsPath, + cause, + }), + ), + ), ), }), Effect.flatMap( @@ -87,7 +103,15 @@ const readClientSettings = ( SchemaError: (cause) => Effect.logWarning("Could not decode desktop client settings.", cause).pipe( Effect.annotateLogs({ settingsPath }), - Effect.as(Option.none()), + Effect.andThen( + Effect.fail( + new DesktopClientSettingsReadError({ + operation: "decode-document", + path: settingsPath, + cause, + }), + ), + ), ), }), ), diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..7262239577b4 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { ); }); + it("requires write access to import agent session history", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsScan)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.agentSessionsImport)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index de6661f45886..7bd1ed6c45f1 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -99,6 +99,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsImport]: AuthOrchestrationOperateScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.attachmentsCreateUploadUrl]: AuthOrchestrationOperateScope, [WS_METHODS.attachmentsDelete]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index c8b143f87ebc..d05ca5ec854a 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -90,6 +90,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.sync(() => { getThreadCheckpointContextCalls += 1; @@ -202,6 +203,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -289,6 +291,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -361,6 +364,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), @@ -418,6 +422,7 @@ describe("CheckpointDiffQuery.layer", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index cb1725882fba..1dae23cdccb0 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -1342,6 +1342,36 @@ describe("CheckpointReactor", () => { ).toBe("v1\n"); }); + it("does not create checkpoints while importing historical user messages", async () => { + const harness = await createHarness({ + hasSession: false, + seedFilesystemCheckpoints: false, + threadWorktreePath: null, + }); + if (runtime === null) throw new Error("Checkpoint test runtime was not initialized."); + + await runtime.runPromise( + harness.engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("cmd-import-history-without-checkpoint"), + threadId: ThreadId.make("thread-1"), + messages: [ + { + messageId: MessageId.make("imported-user-message"), + role: "user", + text: "A message from an existing agent session", + createdAt: "2026-01-01T00:00:00.000Z", + }, + ], + }), + ); + await harness.drain(); + + expect( + gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)), + ).toBe(false); + }); + it("captures turn completion checkpoint from project workspace root when provider session cwd is unavailable", async () => { const harness = await createHarness({ hasSession: false, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index de1cb29c5cd9..0331b0141fb3 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -615,6 +615,7 @@ const make = Effect.gen(function* () { ) { if (event.type === "thread.message-sent") { if ( + event.metadata.historyImport === true || event.payload.role !== "user" || event.payload.streaming || event.payload.turnId !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 866e56ac0378..abfb49b53050 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -418,6 +418,7 @@ describe("OrchestrationEngine", () => { getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 0249844864fb..d81d9b11b1f6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -108,6 +108,102 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-curs }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-")))( + "imported thread shell projection", + (it) => { + it.effect("does not mark imported user messages as queued work in thread shells", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("import:codex:shell-session"); + + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.make("evt-import-shell-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-thread"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-import-shell"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + yield* eventStore.append({ + type: "thread.message-sent", + eventId: EventId.make("evt-import-shell-message"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-message"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:shell-session:0"), + role: "user", + text: "Imported user prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + yield* projectionPipeline.bootstrap; + + const readLatestUserMessageAt = sql<{ readonly latestUserMessageAt: string | null }>` + SELECT latest_user_message_at AS "latestUserMessageAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + + const sessionEvent = yield* eventStore.append({ + type: "thread.session-set", + eventId: EventId.make("evt-import-shell-session"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: createdAt, + commandId: CommandId.make("cmd-import-shell-session"), + causationEventId: null, + correlationId: CommandId.make("cmd-import-shell-session"), + metadata: {}, + payload: { + threadId, + session: { + threadId, + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: createdAt, + }, + }, + }); + yield* projectionPipeline.projectEvent(sessionEvent); + assert.deepEqual(yield* readLatestUserMessageAt, [{ latestUserMessageAt: null }]); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index cd028d502238..b338596993d2 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,10 +1,12 @@ import { ApprovalRequestId, + isImportedAgentSessionMessageId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, ThreadId, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; @@ -248,7 +250,7 @@ function retainProjectionMessagesAfterRevert( } for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.messageId)) { retainedMessageIds.add(message.messageId); continue; } @@ -258,7 +260,10 @@ function retainProjectionMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -271,7 +276,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingUserCount); @@ -281,7 +286,10 @@ function retainProjectionMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.messageId), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.messageId) && + retainedMessageIds.has(message.messageId), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -294,7 +302,7 @@ function retainProjectionMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || + compareDateTimeStrings(left.createdAt, right.createdAt) || left.messageId.localeCompare(right.messageId), ) .slice(0, missingAssistantCount); @@ -903,6 +911,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti updatedAt: event.occurredAt, latestUserMessageAt: event.payload.role === "user" && + !isImportedAgentSessionMessageId(event.payload.messageId) && (previousLatest === null || event.payload.createdAt > previousLatest) ? event.payload.createdAt : previousLatest, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 6330c38ca0c3..a1b351de3535 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,4 +1,5 @@ import { + type AgentSessionImportSource, CheckpointRef, EventId, MessageId, @@ -11,6 +12,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; @@ -2134,7 +2136,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = // // Straggler user message at T03.5 (turn_id NULL, not any pending_message_id) // and a turnless activity at T03.6 — both belong to the page containing T03+. - const seedFanOutThread = Effect.fnUntraced(function* () { + const seedFanOutThread = Effect.fnUntraced(function* (options?: { + readonly importedMessageCount?: number; + }) { const sql = yield* SqlClient.SqlClient; // Tests in this block share one in-memory database; reset before seeding. @@ -2163,6 +2167,20 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = 'turn-5', 0, 0, 0, '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:10.000Z', NULL) `; + if (options?.importedMessageCount) { + for (let index = 0; index < options.importedMessageCount; index += 1) { + const messageId = `import:codex:session-w:${String(index).padStart(6, "0")}`; + const role = index % 2 === 0 ? "user" : "assistant"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, created_at, updated_at + ) + VALUES (${messageId}, 'thread-w', NULL, ${role}, ${"imported message " + index}, 0, + '2026-02-28T00:00:00.000Z', '2026-02-28T00:00:00.000Z') + `; + } + } + const turns: ReadonlyArray<{ turn: string; pendingMessage: string | null; @@ -2396,6 +2414,51 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("keeps imported history on the oldest page after resumed turns", () => + Effect.gen(function* () { + yield* seedFanOutThread({ importedMessageCount: 12 }); + const snapshotQuery = yield* ProjectionSnapshotQuery; + + const completePage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 50 }); + assert.equal(completePage._tag, "Some"); + if (completePage._tag !== "Some") return; + assert.equal( + completePage.value.thread.messages.filter((message) => message.id.startsWith("import:")) + .length, + 12, + ); + assert.equal(completePage.value.page?.hasMore, false); + assert.equal(completePage.value.page?.beforeCursor, null); + + const recentPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { turnLimit: 2 }); + assert.equal(recentPage._tag, "Some"); + if (recentPage._tag !== "Some") return; + assert.equal( + recentPage.value.thread.messages.some((message) => message.id.startsWith("import:")), + false, + ); + const cursor = recentPage.value.page?.beforeCursor; + assert.notEqual(cursor, null); + assert.notEqual(cursor, undefined); + if (cursor === null || cursor === undefined) return; + + const oldestPage = yield* snapshotQuery.getThreadDetailSnapshot(threadW, { + turnLimit: 1, + beforeCursor: cursor, + }); + assert.equal(oldestPage._tag, "Some"); + if (oldestPage._tag !== "Some") return; + + const importedIds = oldestPage.value.thread.messages + .map((message) => message.id) + .filter((messageId) => messageId.startsWith("import:")); + assert.equal(importedIds.length, 12); + assert.equal(new Set(importedIds).size, 12); + assert.equal(oldestPage.value.page?.hasMore, false); + assert.equal(oldestPage.value.page?.beforeCursor, null); + }), + ); + it.effect("a cursor for a different thread degrades to the first page", () => Effect.gen(function* () { yield* seedFanOutThread(); @@ -2779,3 +2842,237 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); }); + +projectionSnapshotLayer("ProjectionSnapshotQuery imported sources", (it) => { + const encodeJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + const source: AgentSessionImportSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex-home"), + providerSessionId: "native-session", + filePath: "/tmp/transcript.jsonl", + size: 128, + mtimeMs: 1_700_000_000_000, + device: 1, + inode: 2, + birthtimeMs: 1_699_000_000_000, + }; + + const seedImportedSession = Effect.fn("seedImportedSession")(function* ( + projectId: ProjectId, + source: AgentSessionImportSource, + ) { + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const timestamp = "2026-03-02T00:00:00.000Z"; + yield* sql` + INSERT OR IGNORE INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at + ) VALUES (${projectId}, 'Imported project', '/tmp/imported-project', '[]', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + created_at, updated_at + ) VALUES (${threadId}, ${projectId}, 'Imported thread', + ${encodeJson({ instanceId: source.providerInstanceId, model: "gpt-5-codex" })}, + 'full-access', 'default', + ${timestamp}, ${timestamp}) + `; + yield* sql` + INSERT INTO provider_session_runtime ( + thread_id, provider_name, provider_instance_id, adapter_key, runtime_mode, status, + last_seen_at, resume_cursor_json, runtime_payload_json + ) VALUES (${threadId}, ${source.provider}, ${source.providerInstanceId}, + ${source.provider}, 'full-access', 'stopped', ${timestamp}, + ${encodeJson({ threadId: source.providerSessionId })}, + ${encodeJson({ importedTranscripts: [source] })}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${`${threadId}:000000`}, ${threadId}, 'user', 'Imported history', 0, + ${timestamp}, ${timestamp}) + `; + return { threadId, source }; + }); + + it.effect("reads completed source copies without decoding message bodies", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-metadata"); + const imported = yield* seedImportedSession(projectId, source); + const copiedSource = { + ...source, + filePath: "/tmp/transcript-copy.jsonl", + mtimeMs: null, + inode: null, + birthtimeMs: null, + }; + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + cwd: "/tmp/imported-project", + importedTranscripts: [source, copiedSource], + })} + WHERE thread_id = ${imported.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET attachments_json = 'not-json' + WHERE thread_id = ${imported.threadId} + `; + + const counter = makeSqlStatementCounter(); + const sources = yield* query + .getImportedAgentSessionSources(projectId) + .pipe(Effect.withTracer(counter.tracer)); + assert.deepEqual(sources, [imported, { threadId: imported.threadId, source: copiedSource }]); + assert.equal(counter.count(), 1); + }), + ); + + it.effect("requires active project threads, a binding, and an imported message", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-completion"); + const completed = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "completed", + }); + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`${completed.threadId}:legacy`} + WHERE thread_id = ${completed.threadId} + `; + const partials = yield* Effect.forEach( + [ + "no-binding", + "no-history", + "no-imported-message", + "wrong-message-thread", + "archived", + "deleted", + ], + (providerSessionId) => seedImportedSession(projectId, { ...source, providerSessionId }), + ); + const [noBinding, noHistory, noImportedMessage, wrongMessageThread, archived, deleted] = + partials; + assert.isDefined(noBinding); + assert.isDefined(noHistory); + assert.isDefined(noImportedMessage); + assert.isDefined(wrongMessageThread); + assert.isDefined(archived); + assert.isDefined(deleted); + yield* sql`DELETE FROM provider_session_runtime WHERE thread_id = ${noBinding.threadId}`; + yield* sql`DELETE FROM projection_thread_messages WHERE thread_id = ${noHistory.threadId}`; + yield* sql` + UPDATE projection_thread_messages SET message_id = ${`normal:${noImportedMessage.threadId}`} + WHERE thread_id = ${noImportedMessage.threadId} + `; + yield* sql` + UPDATE projection_thread_messages SET thread_id = 'unrelated-thread' + WHERE thread_id = ${wrongMessageThread.threadId} + `; + yield* sql` + UPDATE projection_threads SET archived_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${archived.threadId} + `; + yield* sql` + UPDATE projection_threads SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE thread_id = ${deleted.threadId} + `; + const otherProjectId = ProjectId.make("project-import-other"); + const otherProject = yield* seedImportedSession(otherProjectId, { + ...source, + providerSessionId: "other-project", + }); + const deletedProjectId = ProjectId.make("project-import-deleted"); + yield* seedImportedSession(deletedProjectId, { + ...source, + providerSessionId: "deleted-project", + }); + yield* sql` + UPDATE projection_projects SET deleted_at = '2026-03-03T00:00:00.000Z' + WHERE project_id = ${deletedProjectId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [completed]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(otherProjectId), [otherProject]); + assert.deepEqual(yield* query.getImportedAgentSessionSources(deletedProjectId), []); + assert.deepEqual( + yield* query.getImportedAgentSessionSources(ProjectId.make("project-import-missing")), + [], + ); + }), + ); + + it.effect("keeps original sources when the current runtime provider and cursor change", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-switched"); + const imported = yield* seedImportedSession(projectId, { + ...source, + provider: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claude-original"), + providerSessionId: "original-session", + }); + yield* sql` + UPDATE provider_session_runtime + SET provider_name = 'codex', provider_instance_id = 'codex-new', adapter_key = 'codex', + resume_cursor_json = '{"threadId":"new-session"}' + WHERE thread_id = ${imported.threadId} + `; + + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported]); + }), + ); + + it.effect("skips invalid source payloads and entries without dropping valid sources", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const query = yield* ProjectionSnapshotQuery; + const projectId = ProjectId.make("project-import-invalid"); + const imported = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "a-invalid", + }); + const valid = yield* seedImportedSession(projectId, { + ...source, + providerSessionId: "z-valid", + }); + for (const payload of [null, "not-json", "null", "[]", "{}", '{"importedTranscripts":{}}']) { + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = ${payload} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + } + yield* sql` + UPDATE provider_session_runtime SET runtime_payload_json = X'FF' + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [valid]); + + yield* sql` + UPDATE provider_session_runtime + SET runtime_payload_json = ${encodeJson({ + importedTranscripts: [ + null, + {}, + { ...imported.source, size: -1 }, + { ...imported.source, provider: "cursor" }, + { ...imported.source, providerInstanceId: "wrong-instance" }, + { ...imported.source, providerSessionId: "wrong-session" }, + imported.source, + ], + })} + WHERE thread_id = ${imported.threadId} + `; + assert.deepEqual(yield* query.getImportedAgentSessionSources(projectId), [imported, valid]); + }), + ); +}); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 91d09bf57193..86e0b94573d9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1,4 +1,5 @@ import { + AgentSessionImportSource, ApprovalRequestId, ChatAttachment, CheckpointRef, @@ -75,6 +76,14 @@ import { const decodeReadModel = Schema.decodeUnknownEffect(OrchestrationReadModel); const decodeShellSnapshot = Schema.decodeUnknownEffect(OrchestrationShellSnapshot); const decodeThread = Schema.decodeUnknownEffect(OrchestrationThread); +const decodeImportedTranscriptsPayload = Schema.decodeUnknownOption( + Schema.fromJsonString( + Schema.Struct({ + importedTranscripts: Schema.Array(Schema.Unknown), + }), + ), +); +const decodeAgentSessionImportSource = Schema.decodeUnknownOption(AgentSessionImportSource); // Keep detail reads consistent with the in-memory projector's retained // activity window. Applying the limit in SQL avoids decoding an unbounded // payload_json set before the projector can enforce that invariant. @@ -164,6 +173,10 @@ const WorkspaceRootLookupInput = Schema.Struct({ const ProjectIdLookupInput = Schema.Struct({ projectId: ProjectId, }); +const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ + threadId: ThreadId, + runtimePayload: Schema.Unknown, +}); const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); @@ -983,6 +996,33 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listImportedAgentSessionSourceRows = SqlSchema.findAll({ + Request: ProjectIdLookupInput, + Result: ProjectionImportedAgentSessionSourcesRowSchema, + execute: ({ projectId }) => + sql` + SELECT + threads.thread_id AS "threadId", + runtime.runtime_payload_json AS "runtimePayload" + FROM projection_threads AS threads + INNER JOIN projection_projects AS projects + ON projects.project_id = threads.project_id + INNER JOIN provider_session_runtime AS runtime + ON runtime.thread_id = threads.thread_id + WHERE threads.project_id = ${projectId} + AND threads.deleted_at IS NULL + AND threads.archived_at IS NULL + AND projects.deleted_at IS NULL + AND EXISTS ( + SELECT 1 + FROM projection_thread_messages AS messages + WHERE messages.thread_id = threads.thread_id + AND messages.message_id GLOB 'import:*' + ) + ORDER BY threads.thread_id ASC + `, + }); + const getThreadCheckpointContextThreadRow = SqlSchema.findOneOption({ Request: ThreadIdLookupInput, Result: ProjectionThreadCheckpointContextThreadRowSchema, @@ -2663,6 +2703,33 @@ pending_approval_requests AS ( Effect.map(Option.map((row) => row.threadId)), ); + const getImportedAgentSessionSources: ProjectionSnapshotQueryShape["getImportedAgentSessionSources"] = + Effect.fn("ProjectionSnapshotQuery.getImportedAgentSessionSources")(function* (projectId) { + const rows = yield* listImportedAgentSessionSourceRows({ projectId }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getImportedAgentSessionSources:query", + "ProjectionSnapshotQuery.getImportedAgentSessionSources:decodeRows", + ), + ), + ); + return rows.flatMap((row) => { + const payload = decodeImportedTranscriptsPayload(row.runtimePayload); + if (Option.isNone(payload)) return []; + return payload.value.importedTranscripts.flatMap((entry) => { + const source = decodeAgentSessionImportSource(entry); + if ( + Option.isNone(source) || + row.threadId !== + `import:${source.value.providerInstanceId}:${source.value.providerSessionId}` + ) { + return []; + } + return [{ threadId: row.threadId, source: source.value }]; + }); + }); + }); + const getThreadCheckpointContext: ProjectionSnapshotQueryShape["getThreadCheckpointContext"] = ( threadId, ) => @@ -3151,17 +3218,35 @@ pending_approval_requests AS ( ); const oldest = windowRows[0]; + const hasMore = + oldest !== undefined && + (yield* listTurnWindowRows({ + threadId, + beforeAnchorAt: oldest.anchorAt, + beforeTurnKey: oldest.turnKey, + userTurnLimit: 1, + maxRawTurns: 1, + }).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", + "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", + ), + ), + )).length > 0; // An empty window (no turns before the cursor, or a thread with no // turns at all) still returns thread metadata with empty collections // for turn-linked rows; turnless rows are bounded to the same empty // range. The first page of a turnless thread stays unwindowed so - // pre-turn content (e.g. a just-created thread) is not hidden. + // pre-turn content (e.g. a just-created thread) is not hidden. Once + // paging reaches the oldest turn, include turnless messages before + // the first turn, such as history imported from a provider session. const bounds: ThreadDetailBounds | undefined = oldest === undefined && cursor === null ? undefined : { - minAnchorAt: oldest?.anchorAt ?? "", - minTurnKey: oldest?.turnKey ?? "", + minAnchorAt: hasMore ? (oldest?.anchorAt ?? "") : "", + minTurnKey: hasMore ? (oldest?.turnKey ?? "") : "", beforeAnchorAt: cursor?.beforeAnchorAt ?? ANCHOR_UNBOUNDED, beforeTurnKey: cursor?.beforeTurnId ?? "", }; @@ -3178,23 +3263,6 @@ pending_approval_requests AS ( return Option.none(); } - const hasMore = - oldest !== undefined && - (yield* listTurnWindowRows({ - threadId, - beforeAnchorAt: oldest.anchorAt, - beforeTurnKey: oldest.turnKey, - userTurnLimit: 1, - maxRawTurns: 1, - }).pipe( - Effect.mapError( - toPersistenceSqlOrDecodeError( - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:query", - "ProjectionSnapshotQuery.getThreadDetailSnapshot:probeOlder:decodeRows", - ), - ), - )).length > 0; - const { snapshotSequence } = yield* getSnapshotSequence(); const watermarkRow = yield* getThreadEventWatermarkRow({ threadId, @@ -3253,6 +3321,7 @@ pending_approval_requests AS ( getActiveProjectByWorkspaceRoot, getProjectShellById, getFirstActiveThreadIdByProjectId, + getImportedAgentSessionSources, getThreadCheckpointContext, getFullThreadDiffContext, getThreadShellById, diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 96072b266319..37bcc1ae8f39 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -7,6 +7,7 @@ * @module ProjectionSnapshotQuery */ import type { + AgentSessionImportSource, ApprovalRequestId, CheckpointRef, OrchestrationCheckpointSummary, @@ -170,6 +171,15 @@ export interface ProjectionSnapshotQueryShape { projectId: ProjectId, ) => Effect.Effect, ProjectionRepositoryError>; + /** Read completed import sources without loading thread history. */ + readonly getImportedAgentSessionSources: (projectId: ProjectId) => Effect.Effect< + ReadonlyArray<{ + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }>, + ProjectionRepositoryError + >; + /** * Read the checkpoint context needed to resolve a single thread diff. */ diff --git a/apps/server/src/orchestration/decider.import.test.ts b/apps/server/src/orchestration/decider.import.test.ts new file mode 100644 index 000000000000..c809c733800a --- /dev/null +++ b/apps/server/src/orchestration/decider.import.test.ts @@ -0,0 +1,509 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + CommandId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as TestClock from "effect/testing/TestClock"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +it.layer(NodeServices.layer)("thread history import", (it) => { + it.effect("marks imported thread creation without changing live creation", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const projectId = ProjectId.make("project-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-project-created"), + aggregateKind: "project", + aggregateId: projectId, + type: "project.created", + occurredAt: createdAt, + commandId: CommandId.make("command-project-created"), + causationEventId: null, + correlationId: CommandId.make("command-project-created"), + metadata: {}, + payload: { + projectId, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }); + const makeCreateCommand = (threadId: ThreadId) => ({ + type: "thread.create" as const, + commandId: CommandId.make(`command-create-${threadId}`), + threadId, + projectId, + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + createdAt, + }); + + const imported = yield* decideOrchestrationCommand({ + command: { + ...makeCreateCommand(ThreadId.make("import:codex:session-1")), + historyImport: true, + }, + readModel, + }); + const live = yield* decideOrchestrationCommand({ + command: makeCreateCommand(ThreadId.make("live-thread")), + readModel, + }); + + expect(imported).toMatchObject({ + type: "thread.created", + metadata: { historyImport: true }, + }); + expect(live).toMatchObject({ type: "thread.created" }); + expect(live).not.toMatchObject({ metadata: { historyImport: true } }); + }), + ); + + it.effect("settles imported messages at the latest absolute timestamp", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:30:00.000+02:00"; + const threadId = ThreadId.make("import:codex:session-1"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const events = yield* decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-import-history"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt, + }, + { + messageId: MessageId.make(`${threadId}:000001`), + role: "assistant", + text: "Fixed", + createdAt: "2026-08-24T09:00:00.000Z", + }, + ], + }, + readModel, + }); + + expect(events).toMatchObject([ + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "user", text: "Fix the bug", turnId: null, streaming: false }, + }, + { + type: "thread.message-sent", + metadata: { historyImport: true }, + payload: { role: "assistant", text: "Fixed", turnId: null, streaming: false }, + }, + { + type: "thread.settled", + metadata: { historyImport: true }, + occurredAt: "2026-08-24T09:00:00.000Z", + payload: { + settledAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", + }, + }, + ]); + + let projected = readModel; + const plannedEvents = Array.isArray(events) ? events : [events]; + for (const [index, event] of plannedEvents.entries()) { + projected = yield* projectEvent(projected, { ...event, sequence: index + 2 }); + } + projected = yield* projectEvent(projected, { + sequence: 5, + eventId: EventId.make("event-import-reverted"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.reverted", + occurredAt: "2026-08-24T10:02:00.000Z", + commandId: CommandId.make("command-import-reverted"), + causationEventId: null, + correlationId: CommandId.make("command-import-reverted"), + metadata: {}, + payload: { threadId, turnCount: 0 }, + }); + expect(projected.threads[0]?.messages.map((message) => message.text)).toEqual([ + "Fix the bug", + "Fixed", + ]); + }), + ); + + it.effect("allows a thread with a newly imported user message to be settled", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + yield* TestClock.setTime(Date.parse("2026-08-24T10:00:30.000Z")); + const threadId = ThreadId.make("import:codex:session-1"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-import-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-import-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-import-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-import-user-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: createdAt, + commandId: CommandId.make("command-import-user-message"), + causationEventId: null, + correlationId: CommandId.make("command-import-user-message"), + metadata: { historyImport: true }, + payload: { + threadId, + messageId: MessageId.make("import:codex:session-1:0"), + role: "user", + text: "Existing prompt", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + }, + }); + + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.settle", + commandId: CommandId.make("command-settle-imported-thread"), + threadId, + }, + readModel, + }); + + expect(result).toMatchObject({ type: "thread.settled" }); + }), + ); + + it.effect("rejects history import after a client message reaches the thread", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const liveMessageAt = "2026-08-24T10:02:00.000Z"; + const threadId = ThreadId.make("import:codex:client-race"); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-client-race-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-client-race-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make("event-client-race-message"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.message-sent", + occurredAt: liveMessageAt, + commandId: CommandId.make("command-client-race-message"), + causationEventId: null, + correlationId: CommandId.make("command-client-race-message"), + metadata: {}, + payload: { + threadId, + messageId: MessageId.make("client-race-message"), + role: "user", + text: "Start live work", + turnId: null, + streaming: false, + createdAt: liveMessageAt, + updatedAt: liveMessageAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make("command-client-race-import"), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + expect(readModel.threads[0]?.updatedAt).toBe(liveMessageAt); + }), + ); + + for (const requestKind of ["approval.requested", "user-input.requested"] as const) { + it.effect(`rejects history import with an open ${requestKind} activity`, () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make(`import:codex:${requestKind}`); + const withThread = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make(`event-${requestKind}-thread-created`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}-thread-created`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}-thread-created`), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Imported thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + const readModel = yield* projectEvent(withThread, { + sequence: 2, + eventId: EventId.make(`event-${requestKind}`), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.activity-appended", + occurredAt: createdAt, + commandId: CommandId.make(`command-${requestKind}`), + causationEventId: null, + correlationId: CommandId.make(`command-${requestKind}`), + metadata: {}, + payload: { + threadId, + activity: { + id: EventId.make(`activity-${requestKind}`), + tone: "approval", + kind: requestKind, + summary: "Pending request", + payload: { requestId: "request-1" }, + turnId: null, + createdAt, + }, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.history.import", + commandId: CommandId.make(`command-import-${requestKind}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Old work", + createdAt, + }, + ], + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("must be active and empty"); + }), + ); + } + + it.effect("rejects a live user message in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + const error = yield* Effect.flip( + decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("command-live-import-id"), + threadId, + message: { + messageId: MessageId.make("import:forged-live-message"), + role: "user", + text: "Live work", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }, + readModel, + }), + ); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + }), + ); + + it.effect("rejects live assistant messages in the imported-session namespace", () => + Effect.gen(function* () { + const createdAt = "2026-08-24T10:00:00.000Z"; + const threadId = ThreadId.make("thread-live-assistant-message"); + const readModel = yield* projectEvent(createEmptyReadModel(createdAt), { + sequence: 1, + eventId: EventId.make("event-live-assistant-thread-created"), + aggregateKind: "thread", + aggregateId: threadId, + type: "thread.created", + occurredAt: createdAt, + commandId: CommandId.make("command-live-assistant-thread-created"), + causationEventId: null, + correlationId: CommandId.make("command-live-assistant-thread-created"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-1"), + title: "Live thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }); + + for (const commandType of [ + "thread.message.assistant.delta", + "thread.message.assistant.complete", + ] as const) { + const command = + commandType === "thread.message.assistant.delta" + ? { + type: commandType, + commandId: CommandId.make("command-live-assistant-delta-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + delta: "Live work", + createdAt, + } + : { + type: commandType, + commandId: CommandId.make("command-live-assistant-complete-import-id"), + threadId, + messageId: MessageId.make("import:forged-live-assistant-message"), + createdAt, + }; + const error = yield* Effect.flip(decideOrchestrationCommand({ command, readModel })); + + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + expect(error.message).toContain("reserved imported-session namespace"); + } + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index b336053ac9e1..1fd9feba7c44 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -2,12 +2,14 @@ import { EventId, MessageId, UserInputRequestedPayload, + isImportedAgentSessionMessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, type OrchestrationThread, type OrchestrationThreadActivity, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; @@ -98,7 +100,7 @@ function hasQueuedTurnStartForThread( let latestUserMessageAt: string | null = null; let latestUserMessageAtMs = Number.NEGATIVE_INFINITY; for (const message of thread.messages) { - if (message.role !== "user") continue; + if (message.role !== "user" || isImportedAgentSessionMessageId(message.id)) continue; const messageAtMs = Date.parse(message.createdAt); latestUserMessageAtMs = Math.max(latestUserMessageAtMs, messageAtMs); if (messageAtMs === latestUserMessageAtMs) { @@ -347,6 +349,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" aggregateId: command.threadId, occurredAt: command.createdAt, commandId: command.commandId, + ...(command.historyImport === true ? { metadata: { historyImport: true } } : {}), })), type: "thread.created", payload: { @@ -905,6 +908,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.turn.start": { + if (isImportedAgentSessionMessageId(command.message.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.message.messageId}' uses the reserved imported-session namespace.`, + }); + } const targetThread = yield* requireThread({ readModel, command, @@ -1272,6 +1281,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.delta": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1299,6 +1314,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" } case "thread.message.assistant.complete": { + if (isImportedAgentSessionMessageId(command.messageId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message id '${command.messageId}' uses the reserved imported-session namespace.`, + }); + } yield* requireThread({ readModel, command, @@ -1325,6 +1346,79 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.history.import": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + if ( + thread.deletedAt !== null || + thread.archivedAt !== null || + thread.messages.length > 0 || + thread.latestTurn !== null || + thread.session !== null || + hasOpenBlockingRequest(thread) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Thread '${command.threadId}' must be active and empty before history can be imported.`, + }); + } + const firstMessage = command.messages[0]; + if (firstMessage === undefined) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Thread history imports require at least one message.", + }); + } + + const events: Array = []; + for (const message of command.messages) { + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: message.createdAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + turnId: null, + streaming: false, + createdAt: message.createdAt, + updatedAt: message.createdAt, + }, + }); + } + const settledAt = command.messages.reduce( + (latest, message) => + compareDateTimeStrings(message.createdAt, latest) > 0 ? message.createdAt : latest, + firstMessage.createdAt, + ); + events.push({ + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: settledAt, + commandId: command.commandId, + metadata: { historyImport: true }, + })), + type: "thread.settled", + payload: { + threadId: command.threadId, + settledAt, + updatedAt: settledAt, + }, + }); + return events; + } + case "thread.proposed-plan.upsert": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index a558e0ad7af8..77dbe51b9542 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -1,10 +1,12 @@ import type { OrchestrationEvent, OrchestrationReadModel, ThreadId } from "@t3tools/contracts"; import { + isImportedAgentSessionMessageId, OrchestrationCheckpointSummary, OrchestrationMessage, OrchestrationSession, OrchestrationThread, } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import * as Predicate from "effect/Predicate"; @@ -117,7 +119,7 @@ function retainThreadMessagesAfterRevert( ): ReadonlyArray { const retainedMessageIds = new Set(); for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { retainedMessageIds.add(message.id); continue; } @@ -127,7 +129,10 @@ function retainThreadMessagesAfterRevert( } const retainedUserCount = messages.filter( - (message) => message.role === "user" && retainedMessageIds.has(message.id), + (message) => + message.role === "user" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingUserCount = Math.max(0, turnCount - retainedUserCount); if (missingUserCount > 0) { @@ -140,7 +145,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingUserCount); for (const message of fallbackUserMessages) { @@ -149,7 +155,10 @@ function retainThreadMessagesAfterRevert( } const retainedAssistantCount = messages.filter( - (message) => message.role === "assistant" && retainedMessageIds.has(message.id), + (message) => + message.role === "assistant" && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), ).length; const missingAssistantCount = Math.max(0, turnCount - retainedAssistantCount); if (missingAssistantCount > 0) { @@ -162,7 +171,8 @@ function retainThreadMessagesAfterRevert( ) .toSorted( (left, right) => - left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id), + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), ) .slice(0, missingAssistantCount); for (const message of fallbackAssistantMessages) { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index c8fa16158bae..d4a70af59be5 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -12,12 +12,24 @@ const layer = it.layer( ); layer("ProjectionThreadMessageRepository", (it) => { - it.effect("finds the latest user-message time within one thread", () => + it.effect("finds the latest live user-message time within one thread", () => Effect.gen(function* () { const repository = yield* ProjectionThreadMessageRepository; const threadId = ThreadId.make("thread-latest-user-message"); assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + yield* repository.upsert({ + messageId: MessageId.make("import:codex:latest-user-message:000000"), + threadId, + turnId: null, + role: "user", + text: "Imported prompt", + isStreaming: false, + createdAt: "2026-02-28T19:05:06.000Z", + updatedAt: "2026-02-28T19:05:06.000Z", + }); + assert.isNull(yield* repository.getLatestUserMessageAt({ threadId })); + const messages = [ { role: "user", createdAt: "2026-02-28T19:05:02.000Z" }, { role: "user", createdAt: "2026-02-28T19:05:01.000Z" }, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index ce28e11b8601..be20fb37f36d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -191,6 +191,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { SELECT MAX(created_at) AS "latestUserMessageAt" FROM projection_thread_messages WHERE thread_id = ${threadId} AND role = 'user' + AND message_id NOT GLOB 'import:*' `, }); diff --git a/apps/server/src/persistence/ProviderSessionRuntime.ts b/apps/server/src/persistence/ProviderSessionRuntime.ts index 2ccdd862522f..d73f56aab9e0 100644 --- a/apps/server/src/persistence/ProviderSessionRuntime.ts +++ b/apps/server/src/persistence/ProviderSessionRuntime.ts @@ -10,6 +10,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { + AgentSessionImportSource, IsoDateTime, ProviderInstanceId, ProviderSessionRuntimeStatus, @@ -58,6 +59,16 @@ export type GetProviderSessionRuntimeInput = typeof GetProviderSessionRuntimeInp export const DeleteProviderSessionRuntimeInput = Schema.Struct({ threadId: ThreadId }); export type DeleteProviderSessionRuntimeInput = typeof DeleteProviderSessionRuntimeInput.Type; +export const RecordImportedTranscriptInput = Schema.Struct({ + threadId: ThreadId, + source: AgentSessionImportSource, +}); +export type RecordImportedTranscriptInput = typeof RecordImportedTranscriptInput.Type; + +export interface ProviderSessionRuntimeUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + /** * ProviderSessionRuntimeRepository - Service tag for provider runtime persistence. */ @@ -67,10 +78,17 @@ export class ProviderSessionRuntimeRepository extends Context.Service< /** * Insert or replace a provider runtime row. * - * Upserts by canonical `threadId`, including JSON payload/cursor fields. + * Upserts by canonical `threadId`, retaining imported transcript records + * from the current database row. */ readonly upsert: ( runtime: ProviderSessionRuntime, + options?: ProviderSessionRuntimeUpsertOptions, + ) => Effect.Effect; + + /** Record one source file without replacing the current session state. */ + readonly recordImportedTranscript: ( + input: RecordImportedTranscriptInput, ) => Effect.Effect; /** @@ -129,6 +147,10 @@ const GetRuntimeRequestSchema = Schema.Struct({ const DeleteRuntimeRequestSchema = GetRuntimeRequestSchema; +const RecordImportedTranscriptRequestSchema = RecordImportedTranscriptInput.mapFields( + Struct.assign({ source: Schema.fromJsonString(AgentSessionImportSource) }), +); + function toPersistenceSqlOrDecodeError( sqlOperation: string, decodeOperation: string, @@ -147,6 +169,8 @@ function toPersistenceSqlOrDecodeError( export const make = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + // Runtime writes can carry stale payloads. Only recordImportedTranscript may + // change source records, so restore that field from the row being updated. const upsertRuntimeRow = SqlSchema.void({ Request: ProviderSessionRuntimeDbRowSchema, execute: (runtime) => @@ -171,7 +195,11 @@ export const make = Effect.gen(function* () { ${runtime.status}, ${runtime.lastSeenAt}, ${runtime.resumeCursor}, - ${runtime.runtimePayload} + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END ) ON CONFLICT (thread_id) DO UPDATE SET @@ -182,7 +210,107 @@ export const make = Effect.gen(function* () { status = excluded.status, last_seen_at = excluded.last_seen_at, resume_cursor_json = excluded.resume_cursor_json, - runtime_payload_json = excluded.runtime_payload_json + runtime_payload_json = CASE + WHEN json_type( + CASE + WHEN json_valid(provider_session_runtime.runtime_payload_json) + THEN provider_session_runtime.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts' + ) IS NOT NULL + THEN json_set( + CASE + WHEN json_type(excluded.runtime_payload_json) = 'object' + THEN excluded.runtime_payload_json + ELSE '{}' + END, + '$.importedTranscripts', + json_extract(provider_session_runtime.runtime_payload_json, '$.importedTranscripts') + ) + ELSE excluded.runtime_payload_json + END + `, + }); + + const insertRuntimeRow = SqlSchema.void({ + Request: ProviderSessionRuntimeDbRowSchema, + execute: (runtime) => + sql` + INSERT INTO provider_session_runtime ( + thread_id, + provider_name, + provider_instance_id, + adapter_key, + runtime_mode, + status, + last_seen_at, + resume_cursor_json, + runtime_payload_json + ) + VALUES ( + ${runtime.threadId}, + ${runtime.providerName}, + ${runtime.providerInstanceId}, + ${runtime.adapterKey}, + ${runtime.runtimeMode}, + ${runtime.status}, + ${runtime.lastSeenAt}, + ${runtime.resumeCursor}, + CASE + WHEN json_type(${runtime.runtimePayload}) = 'object' + THEN json_remove(${runtime.runtimePayload}, '$.importedTranscripts') + ELSE ${runtime.runtimePayload} + END + ) + ON CONFLICT (thread_id) DO NOTHING + `, + }); + + const recordImportedTranscriptRow = SqlSchema.void({ + Request: RecordImportedTranscriptRequestSchema, + execute: ({ threadId, source }) => + sql` + WITH current_runtime AS ( + SELECT CASE + WHEN json_valid(runtime_payload_json) THEN CASE + WHEN json_type(runtime_payload_json) = 'object' THEN runtime_payload_json + ELSE '{}' + END + ELSE '{}' + END AS payload + FROM provider_session_runtime + WHERE thread_id = ${threadId} + ) + UPDATE provider_session_runtime + SET runtime_payload_json = ( + SELECT json_set( + payload, + '$.importedTranscripts', + json(( + SELECT json_group_array(json(value)) + FROM ( + SELECT value + FROM json_each(CASE + WHEN json_type(payload, '$.importedTranscripts') = 'array' + THEN json_extract(payload, '$.importedTranscripts') + ELSE '[]' + END) + WHERE CASE + WHEN type = 'object' THEN + json_extract(value, '$.providerInstanceId') + IS NOT json_extract(${source}, '$.providerInstanceId') + OR json_extract(value, '$.filePath') IS NOT json_extract(${source}, '$.filePath') + ELSE 0 + END + UNION ALL + SELECT ${source} AS value + ) + )) + ) + FROM current_runtime + ) + WHERE thread_id = ${threadId} `, }); @@ -235,8 +363,8 @@ export const make = Effect.gen(function* () { `, }); - const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime) => - upsertRuntimeRow(runtime).pipe( + const upsert: ProviderSessionRuntimeRepository["Service"]["upsert"] = (runtime, options) => + (options?.onConflict === "ignore" ? insertRuntimeRow(runtime) : upsertRuntimeRow(runtime)).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( "ProviderSessionRuntimeRepository.upsert:query", @@ -246,6 +374,18 @@ export const make = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionRuntimeRepository["Service"]["recordImportedTranscript"] = + (input) => + recordImportedTranscriptRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProviderSessionRuntimeRepository.recordImportedTranscript:query", + "ProviderSessionRuntimeRepository.recordImportedTranscript:encodeRequest", + { threadId: input.threadId }, + ), + ), + ); + const getByThreadId: ProviderSessionRuntimeRepository["Service"]["getByThreadId"] = (input) => getRuntimeRowByThreadId(input).pipe( Effect.mapError( @@ -324,6 +464,7 @@ export const make = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getByThreadId, list, deleteByThreadId, diff --git a/apps/server/src/project/AgentSessionImporter.test.ts b/apps/server/src/project/AgentSessionImporter.test.ts new file mode 100644 index 000000000000..38d6ad1d4331 --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.test.ts @@ -0,0 +1,1213 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it, vi } from "@effect/vitest"; +import { + AgentSessionImportProjectChangedError, + CommandId, + MessageId, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationProjectShell, + type OrchestrationThread, + type ProviderSendTurnInput, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import { makeTestProviderAdapterHarness } from "../../integration/TestProviderAdapter.integration.ts"; +import { ServerConfig } from "../config.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import { OrchestrationCommandReceiptRepositoryLive } from "../persistence/Layers/OrchestrationCommandReceipts.ts"; +import { OrchestrationEventStoreLive } from "../persistence/Layers/OrchestrationEventStore.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as ProviderSessionRuntime from "../persistence/ProviderSessionRuntime.ts"; +import { OrchestrationEngineLive } from "../orchestration/Layers/OrchestrationEngine.ts"; +import { OrchestrationProjectionPipelineLive } from "../orchestration/Layers/ProjectionPipeline.ts"; +import { OrchestrationProjectionSnapshotQueryLive } from "../orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactorLive } from "../orchestration/Layers/ProviderCommandReactor.ts"; +import { OrchestrationCommandInvariantError } from "../orchestration/Errors.ts"; +import * as ThreadBackgroundLiveness from "../orchestration/ThreadBackgroundLiveness.ts"; +import * as ThreadPlanProgress from "../orchestration/ThreadPlanProgress.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { ProviderCommandReactor } from "../orchestration/Services/ProviderCommandReactor.ts"; +import { ProviderSessionDirectoryLive } from "../provider/Layers/ProviderSessionDirectory.ts"; +import { makeProviderServiceLive } from "../provider/Layers/ProviderService.ts"; +import { + NoOpProviderEventLoggers, + ProviderEventLoggers, +} from "../provider/Layers/ProviderEventLoggers.ts"; +import { ProviderSessionDirectoryPersistenceError } from "../provider/Errors.ts"; +import { ProviderAdapterRegistry } from "../provider/Services/ProviderAdapterRegistry.ts"; +import { ProviderAuthService } from "../provider/Services/ProviderAuthService.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import { makeAdapterRegistryMock } from "../provider/testUtils/providerAdapterRegistryMock.ts"; +import { makeProviderRegistryLayer } from "../provider/testUtils/providerRegistryMock.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import * as AnalyticsService from "../telemetry/AnalyticsService.ts"; +import { TextGeneration } from "../textGeneration/TextGeneration.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import * as RepositoryIdentityResolver from "./RepositoryIdentityResolver.ts"; +import { importRecentAgentThreads } from "./AgentSessionImporter.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const PROJECT_ID = ProjectId.make("project-1"); +const WORKSPACE_ROOT = "/tmp/project-from-server"; +const CLAUDE_SESSION_ID = "123e4567-e89b-42d3-a456-426614174000"; +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +const makeThread = (source: "codex" | "claudeAgent"): AgentSessionScanner.AgentSessionThread => ({ + source, + providerInstanceId: ProviderInstanceId.make(source), + providerSessionId: source === "codex" ? "codex-session" : CLAUDE_SESSION_ID, + title: `Imported ${source} thread`, + model: null, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:01:00.000Z", + messages: [ + { role: "user", text: "Fix the bug", createdAt: "2026-08-24T10:00:00.000Z" }, + { role: "assistant", text: "Fixed", createdAt: "2026-08-24T10:01:00.000Z" }, + ], +}); + +const makeThreadOutcome = (thread: AgentSessionScanner.AgentSessionThread) => + ({ + _tag: "Importable", + thread, + source: { + provider: thread.source, + providerInstanceId: thread.providerInstanceId, + providerSessionId: thread.providerSessionId, + filePath: `/tmp/transcripts/${thread.providerInstanceId}/${thread.providerSessionId}.jsonl`, + size: 0, + mtimeMs: 0, + device: 0, + inode: 0, + birthtimeMs: 0, + }, + }) satisfies AgentSessionScanner.AgentSessionRecentThread; + +const makeProject = (): OrchestrationProjectShell => ({ + id: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-24T09:00:00.000Z", + updatedAt: "2026-08-24T09:00:00.000Z", +}); + +const makeProjectedThread = (input: { + readonly source: "codex" | "claudeAgent"; + readonly projectId?: ProjectId; + readonly imported?: boolean; + readonly includeFollowup?: boolean; +}): OrchestrationThread => { + const sourceThread = makeThread(input.source); + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + return { + id: threadId, + projectId: input.projectId ?? PROJECT_ID, + title: sourceThread.title, + modelSelection: { instanceId: sourceThread.providerInstanceId, model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: sourceThread.createdAt, + updatedAt: sourceThread.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: input.imported + ? [ + { + id: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:00:00.000Z", + updatedAt: "2026-08-24T10:00:00.000Z", + }, + ...(input.includeFollowup + ? [ + { + id: MessageId.make("user-followup"), + role: "user" as const, + text: "Keep going", + turnId: null, + streaming: false, + createdAt: "2026-08-24T10:02:00.000Z", + updatedAt: "2026-08-24T10:02:00.000Z", + }, + ] + : []), + ] + : [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }; +}; + +const makeSnapshotsLayer = (input: { + readonly project?: OrchestrationProjectShell; + readonly getThread?: (threadId: ThreadId) => Option.Option; +}) => + Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getProjectShellById: () => + Effect.succeed(input.project === undefined ? Option.none() : Option.some(input.project)), + getImportedAgentSessionSources: () => Effect.succeed([]), + getThreadDetailById: (threadId) => Effect.succeed(input.getThread?.(threadId) ?? Option.none()), + }); + +const runImport = (input: { + readonly scanner: AgentSessionScanner.AgentSessionScanner["Service"]; + readonly engine: OrchestrationEngine.OrchestrationEngineService["Service"]; + readonly directory: ProviderSessionDirectory.ProviderSessionDirectory["Service"]; + readonly snapshots: ReturnType; + readonly expectedWorkspaceRoot?: string; +}) => + importRecentAgentThreads({ + projectId: PROJECT_ID, + ...(input.expectedWorkspaceRoot === undefined + ? {} + : { expectedWorkspaceRoot: input.expectedWorkspaceRoot }), + }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, input.scanner), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, input.engine), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, input.directory), + Effect.provide(input.snapshots), + ); + +it.layer(NodeServices.layer)("AgentSessionImporter", (it) => { + describe("importRecentAgentThreads", () => { + it.effect("uses the project root and stores provider-specific resume cursors", () => + Effect.gen(function* () { + const commands: Array = []; + const bindings: Array = []; + let scannedRoot: string | undefined; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: (workspaceRoot) => { + scannedRoot = workspaceRoot; + return Stream.concat( + Stream.succeed(makeThreadOutcome(makeThread("codex"))), + Stream.fromEffect( + Effect.sync(() => { + expect(bindings).toHaveLength(1); + return makeThreadOutcome(makeThread("claudeAgent")); + }), + ), + ); + }, + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => Effect.sync(() => void bindings.push(binding)), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + expectedWorkspaceRoot: `${WORKSPACE_ROOT}/`, + }); + + expect(result).toEqual({ importedCount: 2, skippedCount: 0 }); + expect(scannedRoot).toBe(WORKSPACE_ROOT); + expect(commands.map((command) => command.type)).toEqual([ + "thread.create", + "thread.history.import", + "thread.create", + "thread.history.import", + ]); + expect(commands.filter((command) => command.type === "thread.create")).toMatchObject([ + { historyImport: true }, + { historyImport: true }, + ]); + expect( + commands + .filter((command) => command.type === "thread.history.import") + .flatMap((command) => command.messages.map((message) => message.messageId)), + ).toEqual([ + "import:codex:codex-session:000000", + "import:codex:codex-session:000001", + `import:claudeAgent:${CLAUDE_SESSION_ID}:000000`, + `import:claudeAgent:${CLAUDE_SESSION_ID}:000001`, + ]); + expect(bindings).toMatchObject([ + { + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + { + provider: "claudeAgent", + providerInstanceId: "claudeAgent", + resumeCursor: { + threadId: `import:claudeAgent:${CLAUDE_SESSION_ID}`, + resume: CLAUDE_SESSION_ID, + }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }, + ]); + }), + ); + + it.effect("rejects a changed project root before scanning or writing", () => + Effect.gen(function* () { + const recentThreads = vi.fn(() => Stream.empty); + const error = yield* importRecentAgentThreads({ + projectId: PROJECT_ID, + expectedWorkspaceRoot: WORKSPACE_ROOT, + }).pipe( + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("must not scan a changed project"), + recentThreads, + }), + ), + Effect.provide( + Layer.mergeAll( + Layer.mock(OrchestrationEngine.OrchestrationEngineService)({}), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({}), + makeSnapshotsLayer({ + project: { ...makeProject(), workspaceRoot: "/tmp/project-moved" }, + }), + ), + ), + Effect.flip, + ); + + expect(error).toEqual(new AgentSessionImportProjectChangedError({ projectId: PROJECT_ID })); + expect(recentThreads).not.toHaveBeenCalled(); + }), + ); + + it.effect("counts scanner skips without writing a thread or binding", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed({ _tag: "Skipped" }), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not dispatch for a scanner skip"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind a scanner skip"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.die("must not read a scanner skip binding"), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ project: makeProject() }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 1 }); + }), + ); + + it.effect("recovers after a rejected history receipt and a failed binding write", () => + Effect.gen(function* () { + let threadCreated = false; + let historyImported = false; + let historyAttemptCount = 0; + let bindingAttemptCount = 0; + const rejectedCommandIds = new Set(); + const bindings: Array = []; + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => { + if (rejectedCommandIds.has(command.commandId)) { + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Previously rejected.", + }), + ); + } + if (command.type === "thread.create") threadCreated = true; + if (command.type === "thread.history.import") { + historyAttemptCount += 1; + if (historyAttemptCount === 1) { + rejectedCommandIds.add(command.commandId); + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Temporary history import failure.", + }), + ); + } + historyImported = true; + } + return Effect.succeed({ sequence: 1 }); + }, + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: (binding) => { + bindingAttemptCount += 1; + if (bindingAttemptCount === 1) { + return Effect.fail( + new ProviderSessionDirectoryPersistenceError({ + operation: "upsert", + detail: "Temporary session storage failure.", + }), + ); + } + bindings.push(binding); + return Effect.void; + }, + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => + Effect.succeed(bindings[0] === undefined ? Option.none() : Option.some(bindings[0])), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const snapshots = makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + threadCreated + ? Option.some(makeProjectedThread({ source: "codex", imported: historyImported })) + : Option.none(), + }); + const importOnce = () => runImport({ scanner, engine, directory, snapshots }); + + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + const historyAttemptsAfterCompletion = historyAttemptCount; + expect(yield* importOnce()).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(historyAttemptCount).toBe(historyAttemptsAfterCompletion); + expect(historyAttemptCount).toBe(2); + expect(bindings).toHaveLength(1); + }), + ); + + it.effect("does not replace completed history or an active binding on retry", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(makeThread("codex"))]), + }); + const runningBinding: ProviderSessionDirectory.ProviderRuntimeBinding = { + threadId: ThreadId.make("import:codex:codex-session"), + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "newer-codex-session" }, + }; + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not replace an active binding"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.void, + getBinding: () => Effect.succeed(Option.some(runningBinding)), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: () => Effect.die("must not replay history or settle active work"), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: () => + Option.some( + makeProjectedThread({ source: "codex", imported: true, includeFollowup: true }), + ), + }), + }); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + }), + ); + + it.effect("skips malformed Claude ids and wrong-project thread collisions", () => + Effect.gen(function* () { + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.fromIterable([ + makeThreadOutcome({ ...makeThread("claudeAgent"), providerSessionId: "not-a-uuid" }), + makeThreadOutcome(makeThread("codex")), + ]), + }); + const commands: Array = []; + const engine = OrchestrationEngine.OrchestrationEngineService.of({ + dispatch: (command) => Effect.sync(() => ({ sequence: commands.push(command) })), + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused"), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + }); + const directory = ProviderSessionDirectory.ProviderSessionDirectory.of({ + upsert: () => Effect.die("must not bind malformed or wrong-project sessions"), + getProvider: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.die("unused"), + listBindings: () => Effect.die("unused"), + }); + + const result = yield* runImport({ + scanner, + engine, + directory, + snapshots: makeSnapshotsLayer({ + project: makeProject(), + getThread: (threadId) => + threadId === "import:codex:codex-session" + ? Option.some( + makeProjectedThread({ + source: "codex", + projectId: ProjectId.make("project-other"), + }), + ) + : Option.none(), + }), + }); + + expect(result).toEqual({ importedCount: 0, skippedCount: 2 }); + expect(commands).toHaveLength(0); + }), + ); + }); +}); + +const integrationThread = { + ...makeThread("codex"), + updatedAt: "2026-08-24T10:00:00.000Z", + messages: Array.from({ length: 12 }, (_, index) => ({ + role: index % 2 === 0 ? ("user" as const) : ("assistant" as const), + text: `Message ${index}`, + createdAt: "2026-08-24T10:00:00.000Z", + })), +}; +const integrationScanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.fromIterable([makeThreadOutcome(integrationThread)]), +}); +const integrationServerConfig = ServerConfig.layerTest(process.cwd(), { + prefix: "t3-agent-session-importer-test-", +}); +const integrationRuntimeRepository = ProviderSessionRuntime.layer.pipe( + Layer.provide(SqlitePersistenceMemory), +); +const integrationLayer = Layer.mergeAll( + OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationProjectionPipelineLive), + ), + OrchestrationProjectionSnapshotQueryLive, + integrationRuntimeRepository, + ProviderSessionDirectoryLive.pipe(Layer.provide(integrationRuntimeRepository)), + Layer.succeed(AgentSessionScanner.AgentSessionScanner, integrationScanner), +).pipe( + Layer.provide(ThreadBackgroundLiveness.layer), + Layer.provide(ThreadPlanProgress.layer), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(RepositoryIdentityResolver.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provideMerge(integrationServerConfig), + Layer.provideMerge(NodeServices.layer), +); + +it.layer(integrationLayer)("AgentSessionImporter integration", (it) => { + it.effect("imports once after the real engine persists an old rejected receipt", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:codex-session"); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-integration-project"), + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: WORKSPACE_ROOT, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + const rejected = yield* Effect.result( + engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(`agent-session:history:${threadId}`), + threadId, + messages: [ + { + messageId: MessageId.make(`${threadId}:000000`), + role: "user", + text: "Fix the bug", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }), + ); + expect(rejected._tag).toBe("Failure"); + + const result = yield* importRecentAgentThreads({ projectId: PROJECT_ID }); + const importedThread = yield* snapshots.getThreadDetailById(threadId); + const binding = yield* directory.getBinding(threadId); + + expect(result).toEqual({ importedCount: 1, skippedCount: 0 }); + expect(Option.getOrThrow(importedThread).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + expect(Option.getOrThrow(importedThread).settledOverride).toBe("settled"); + expect(Option.getOrThrow(importedThread).updatedAt).toBe("2026-08-24T10:00:00.000Z"); + expect(Option.getOrThrow(binding)).toMatchObject({ + provider: "codex", + providerInstanceId: "codex", + resumeCursor: { threadId: "codex-session" }, + runtimePayload: { cwd: WORKSPACE_ROOT }, + }); + + yield* engine.dispatch({ + type: "thread.revert.complete", + commandId: CommandId.make("revert-imported-thread-to-baseline"), + threadId, + turnCount: 0, + createdAt: "2026-08-24T10:05:00.000Z", + }); + const afterRevert = yield* snapshots.getThreadDetailById(threadId); + expect(Option.getOrThrow(afterRevert).messages.map((message) => message.text)).toEqual( + integrationThread.messages.map((message) => message.text), + ); + }), + ); + + it.effect( + "retries a bounded import after scanner restart without rereading completed transcripts", + () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-import-retry-", + }); + const workspaceRoot = path.join(fixtureDir, "workspace"); + const claudeHomePath = path.join(fixtureDir, "claude"); + const codexHomePath = path.join(fixtureDir, "codex"); + const sessionsDir = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* fileSystem.makeDirectory(workspaceRoot); + yield* fileSystem.makeDirectory(claudeHomePath); + yield* fileSystem.makeDirectory(sessionsDir, { recursive: true }); + + const projectId = ProjectId.make("project-bounded-import-retry"); + const transcripts = Array.from({ length: 101 }, (_, index) => { + const providerSessionId = `bounded-session-${String(index).padStart(3, "0")}`; + return { + providerSessionId, + threadId: ThreadId.make(`import:codex:${providerSessionId}`), + filePath: path.join(sessionsDir, `rollout-${providerSessionId}.jsonl`), + }; + }); + for (const [index, transcript] of transcripts.entries()) { + yield* fileSystem.writeFileString( + transcript.filePath, + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: transcript.providerSessionId, cwd: workspaceRoot }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: `Prompt ${transcript.providerSessionId}`, + }, + }), + ].join("\n"), + ); + const seconds = nowMs / 1_000 - index; + yield* fileSystem.utimes(transcript.filePath, seconds, seconds); + } + const legacy = transcripts[0]!; + const failed = transcripts[1]!; + const remaining = transcripts[100]!; + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-bounded-import-project"), + projectId, + title: "Bounded import", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + // This completed import predates persisted transcript source metadata. + yield* directory.upsert({ + threadId: legacy.threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "legacy-current-session" }, + runtimePayload: { cwd: workspaceRoot }, + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-legacy-bounded-import"), + threadId: legacy.threadId, + projectId, + title: "Legacy import", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + historyImport: true, + }); + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make("import-legacy-bounded-history"), + threadId: legacy.threadId, + messages: [ + { + messageId: MessageId.make(`${legacy.threadId}:000000`), + role: "user", + text: "Legacy imported history", + createdAt: "2026-08-24T10:00:00.000Z", + }, + ], + }); + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toEqual([]); + + let failHistory = true; + const importerEngine = OrchestrationEngine.OrchestrationEngineService.of({ + ...engine, + dispatch: (command) => { + if ( + failHistory && + command.type === "thread.history.import" && + command.threadId === failed.threadId + ) { + failHistory = false; + return Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: "Injected history import failure.", + }), + ); + } + return engine.dispatch(command); + }, + }); + const settingsLayer = ServerSettingsService.layerTest({ + providers: { + claudeAgent: { homePath: claudeHomePath }, + codex: { homePath: codexHomePath }, + }, + }); + const transcriptPaths = new Set(transcripts.map((transcript) => transcript.filePath)); + const runAttempt = Effect.fn("runBoundedImportAttempt")(function* ( + completedPaths: ReadonlySet, + ) { + const openCounts = new Map(); + const fullReads: string[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => + Effect.suspend(() => { + if (transcriptPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + // A fresh scanner first opens each file for project discovery. + if (count > 1) { + fullReads.push(filePath); + if (completedPaths.has(filePath)) { + return Effect.die(new Error(`Completed transcript reopened: ${filePath}`)); + } + } + } + return fileSystem.open(filePath, options); + }), + }); + const result = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provide( + Layer.fresh(AgentSessionScanner.layer).pipe( + Layer.provide(settingsLayer), + Layer.provide(Layer.succeed(FileSystem.FileSystem, observedFileSystem)), + ), + ), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, importerEngine), + ); + return { result, fullReads, openCounts }; + }); + + const first = yield* runAttempt(new Set()); + expect(first.result).toEqual({ importedCount: 99, skippedCount: 2 }); + expect(failHistory).toBe(false); + expect(first.fullReads).toEqual(transcripts.slice(0, 100).map((entry) => entry.filePath)); + expect(first.openCounts.get(remaining.filePath)).toBe(1); + const completedSources = yield* snapshots.getImportedAgentSessionSources(projectId); + expect(completedSources).toHaveLength(99); + expect(completedSources).toContainEqual({ + threadId: legacy.threadId, + source: expect.objectContaining({ filePath: legacy.filePath }), + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(failed.threadId)).messages, + ).toEqual([]); + expect(Option.getOrThrow(yield* directory.getBinding(failed.threadId))).toMatchObject({ + status: "stopped", + resumeCursor: { threadId: failed.providerSessionId }, + }); + expect(Option.isNone(yield* snapshots.getThreadDetailById(remaining.threadId))).toBe(true); + + const completedPaths = new Set(completedSources.map((entry) => entry.source.filePath)); + const second = yield* runAttempt(completedPaths); + expect(second.result).toEqual({ importedCount: 101, skippedCount: 0 }); + expect(second.fullReads).toEqual([failed.filePath, remaining.filePath]); + for (const transcript of transcripts) { + expect(second.openCounts.get(transcript.filePath)).toBe( + completedPaths.has(transcript.filePath) ? 1 : 2, + ); + } + expect(yield* snapshots.getImportedAgentSessionSources(projectId)).toHaveLength(101); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(legacy.threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Legacy imported history"]); + expect( + Option.getOrThrow(yield* directory.getBinding(legacy.threadId)).resumeCursor, + ).toEqual({ + threadId: "legacy-current-session", + }); + for (const transcript of [failed, remaining]) { + expect( + Option.getOrThrow( + yield* snapshots.getThreadDetailById(transcript.threadId), + ).messages.map((message) => message.text), + ).toEqual([`Prompt ${transcript.providerSessionId}`]); + } + }), + ); + + for (const source of ["codex", "claudeAgent"] as const) { + it.effect(`resumes imported ${source} history only after the first prompt`, () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const fileSystem = yield* FileSystem.FileSystem; + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped(); + const projectId = ProjectId.make(`project-import-resume-${source}`); + const sourceThread = { + ...makeThread(source), + providerSessionId: source === "codex" ? "codex-first-resume" : CLAUDE_SESSION_ID, + }; + const threadId = ThreadId.make( + `import:${sourceThread.providerInstanceId}:${sourceThread.providerSessionId}`, + ); + const resumeCursor = + source === "codex" + ? { threadId: sourceThread.providerSessionId } + : { threadId, resume: sourceThread.providerSessionId }; + const provider = ProviderDriverKind.make(source); + const harness = yield* makeTestProviderAdapterHarness({ provider }); + const importSettled = yield* Deferred.make(); + const turnSent = yield* Deferred.make(); + const startSession = vi.fn(harness.adapter.startSession); + const sendTurn = vi.fn((input: ProviderSendTurnInput) => + harness.adapter + .sendTurn(input) + .pipe(Effect.tap(() => Deferred.succeed(turnSent, undefined))), + ); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry, + makeAdapterRegistryMock({ + [provider]: { ...harness.adapter, startSession, sendTurn }, + }), + ), + ), + Layer.provide( + Layer.succeed(ProviderSessionDirectory.ProviderSessionDirectory, directory), + ), + Layer.provide(Layer.succeed(ProviderEventLoggers, NoOpProviderEventLoggers)), + Layer.provide(AnalyticsService.layerTest), + ); + const reactorLayer = ProviderCommandReactorLive.pipe( + Layer.provideMerge(providerLayer), + Layer.provide( + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + ...snapshots, + // Acknowledge the imported settlement before draining the reactor. + getThreadShellById: (requestedThreadId) => + snapshots + .getThreadShellById(requestedThreadId) + .pipe( + Effect.tap(() => + requestedThreadId === threadId + ? Deferred.succeed(importSettled, undefined) + : Effect.void, + ), + ), + }), + ), + Layer.provide( + Layer.mock(ProviderAuthService)({ + tryHandlePromptCommand: () => Effect.succeed(false), + }), + ), + Layer.provide(makeProviderRegistryLayer()), + Layer.provide(Layer.mock(GitWorkflowService)({})), + Layer.provide(Layer.mock(VcsStatusBroadcaster)({})), + Layer.provide(Layer.mock(TextGeneration)({})), + Layer.provide(ServerSettingsService.layerTest()), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make(`create-import-resume-project-${source}`), + projectId, + title: "Import resume", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* harness.queueTurnResponseForNextSession({ events: [] }); + + yield* Effect.gen(function* () { + const reactor = yield* ProviderCommandReactor; + yield* reactor.start(); + expect(yield* importRecentAgentThreads({ projectId })).toEqual({ + importedCount: 1, + skippedCount: 0, + }); + yield* Deferred.await(importSettled); + yield* reactor.drain; + expect(startSession).not.toHaveBeenCalled(); + expect(sendTurn).not.toHaveBeenCalled(); + const importedThread = Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)); + expect(importedThread.session).toBeNull(); + expect(importedThread.latestTurn).toBeNull(); + + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`resume-imported-${source}`), + threadId, + message: { + messageId: MessageId.make(`resume-imported-message-${source}`), + role: "user", + text: "Continue this session", + attachments: [], + }, + modelSelection: importedThread.modelSelection, + runtimeMode: importedThread.runtimeMode, + interactionMode: importedThread.interactionMode, + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.await(turnSent); + yield* reactor.drain; + expect(startSession).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ + threadId, + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + cwd: workspaceRoot, + }), + ); + expect(sendTurn).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ threadId, input: "Continue this session" }), + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + provider, + providerInstanceId: sourceThread.providerInstanceId, + resumeCursor, + }); + }).pipe( + Effect.provide(reactorLayer), + Effect.provideService( + AgentSessionScanner.AgentSessionScanner, + AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => Stream.succeed(makeThreadOutcome(sourceThread)), + }), + ), + ); + }), + ); + } + + it.effect("persists the resume cursor before publishing a new imported thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-binding-race"); + const workspaceRoot = "/tmp/project-import-binding-race"; + const providerSessionId = "codex-binding-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Binding race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-binding-race-project"), + projectId, + title: "Binding race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + + yield* Effect.raceFirst( + Deferred.await(importerAtBindingWrite), + Fiber.join(importFiber).pipe( + Effect.flatMap((result) => + Effect.die( + new Error(`Import completed before the binding write: ${JSON.stringify(result)}`), + ), + ), + ), + ); + expect(Option.isNone(yield* snapshots.getThreadDetailById(threadId))).toBe(true); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 1, skippedCount: 0 }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(integrationThread.messages.map((message) => message.text)); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + }), + ); + + it.effect("does not import history over a turn started on a partial thread", () => + Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const projectId = ProjectId.make("project-import-turn-race"); + const workspaceRoot = "/tmp/project-import-turn-race"; + const providerSessionId = "codex-turn-race"; + const threadId = ThreadId.make(`import:codex:${providerSessionId}`); + const scanner = AgentSessionScanner.AgentSessionScanner.of({ + scan: Effect.die("unused"), + recentThreads: () => + Stream.succeed( + makeThreadOutcome({ ...integrationThread, providerSessionId, title: "Turn race" }), + ), + }); + const importerAtBindingWrite = yield* Deferred.make(); + const releaseImporter = yield* Deferred.make(); + const importerRepository = ProviderSessionRuntime.ProviderSessionRuntimeRepository.of({ + ...repository, + upsert: (runtime, options) => + options?.onConflict === "ignore" + ? Deferred.succeed(importerAtBindingWrite, undefined).pipe( + Effect.andThen(Deferred.await(releaseImporter)), + Effect.andThen(repository.upsert(runtime, options)), + ) + : repository.upsert(runtime, options), + }); + const importerDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory.pipe( + Effect.provide( + Layer.fresh(ProviderSessionDirectoryLive).pipe( + Layer.provide( + Layer.succeed( + ProviderSessionRuntime.ProviderSessionRuntimeRepository, + importerRepository, + ), + ), + ), + ), + ); + + yield* engine.dispatch({ + type: "project.create", + commandId: CommandId.make("create-import-turn-race-project"), + projectId, + title: "Turn race", + workspaceRoot, + defaultModelSelection: null, + createdAt: "2026-08-24T09:00:00.000Z", + }); + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("create-import-turn-race-thread"), + threadId, + projectId, + title: "Turn race", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "default" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt: "2026-08-24T10:00:00.000Z", + }); + + const importFiber = yield* importRecentAgentThreads({ projectId }).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, scanner), + Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, importerDirectory), + Effect.forkChild, + ); + yield* Deferred.await(importerAtBindingWrite); + + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + yield* engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-turn-during-import"), + threadId, + message: { + messageId: MessageId.make("message-during-import"), + role: "user", + text: "Continue while import waits", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-08-24T10:02:00.000Z", + }); + yield* Deferred.succeed(releaseImporter, undefined); + + expect(yield* Fiber.join(importFiber)).toEqual({ importedCount: 0, skippedCount: 1 }); + expect(Option.getOrThrow(yield* directory.getBinding(threadId))).toMatchObject({ + status: "running", + resumeCursor: { threadId: "active-client-session" }, + runtimePayload: { cwd: workspaceRoot, activeTurnId: "turn-active" }, + }); + expect( + Option.getOrThrow(yield* snapshots.getThreadDetailById(threadId)).messages.map( + (message) => message.text, + ), + ).toEqual(["Continue while import waits"]); + }), + ); +}); diff --git a/apps/server/src/project/AgentSessionImporter.ts b/apps/server/src/project/AgentSessionImporter.ts new file mode 100644 index 000000000000..3820c2cf411a --- /dev/null +++ b/apps/server/src/project/AgentSessionImporter.ts @@ -0,0 +1,297 @@ +import { + CommandId, + DEFAULT_MODEL, + DEFAULT_MODEL_BY_PROVIDER, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionSource, + AgentSessionScanError, + isImportedAgentSessionMessageId, + MessageId, + ProjectId, + ProviderDriverKind, + ThreadId, + type AgentSessionImportInput, + type AgentSessionImportResult, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderSessionDirectory from "../provider/Services/ProviderSessionDirectory.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const CLAUDE_SESSION_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +class AgentSessionUnresumableSessionError extends Schema.TaggedErrorClass()( + "AgentSessionUnresumableSessionError", + { + source: AgentSessionSource, + providerSessionId: Schema.String, + }, +) { + override get message(): string { + return `Session '${this.providerSessionId}' from '${this.source}' cannot be resumed.`; + } +} + +class AgentSessionThreadProjectConflictError extends Schema.TaggedErrorClass()( + "AgentSessionThreadProjectConflictError", + { + threadId: ThreadId, + expectedProjectId: ProjectId, + actualProjectId: ProjectId, + }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' belongs to project '${this.actualProjectId}', not '${this.expectedProjectId}'.`; + } +} + +class AgentSessionThreadModifiedError extends Schema.TaggedErrorClass()( + "AgentSessionThreadModifiedError", + { threadId: ThreadId }, +) { + override get message(): string { + return `Imported thread '${this.threadId}' changed before its history import completed.`; + } +} + +function hasImportedHistory(thread: OrchestrationThread): boolean { + return thread.messages.some((message) => isImportedAgentSessionMessageId(message.id)); +} + +function hasImportBlockingActivity( + thread: OrchestrationThread, + importedHistoryPresent: boolean, +): boolean { + return ( + thread.archivedAt !== null || + thread.deletedAt !== null || + thread.latestTurn !== null || + thread.session !== null || + thread.messages.some((message) => !isImportedAgentSessionMessageId(message.id)) || + thread.proposedPlans.length > 0 || + thread.activities.length > 0 || + thread.checkpoints.length > 0 || + thread.snoozedUntil != null || + thread.snoozedAt != null || + thread.pinnedAt != null || + thread.pinOrderKey != null || + thread.titleRegeneration != null || + thread.linkedPullRequest != null || + thread.unsettledAt != null || + (importedHistoryPresent + ? thread.settledOverride !== "settled" + : thread.settledOverride !== null || thread.settledAt !== null) + ); +} + +/** Import recent transcript text and persist the cursor needed to resume its provider session. */ +export const importRecentAgentThreads = Effect.fn("importRecentAgentThreads")(function* ( + input: AgentSessionImportInput, +) { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const crypto = yield* Crypto.Crypto; + const project = yield* snapshots.getProjectShellById(input.projectId).pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + Effect.flatMap( + Option.match({ + onNone: () => + Effect.fail(new AgentSessionImportProjectNotFoundError({ projectId: input.projectId })), + onSome: Effect.succeed, + }), + ), + ); + const workspaceRoot = project.workspaceRoot; + if ( + input.expectedWorkspaceRoot !== undefined && + normalizeProjectPathForComparison(workspaceRoot) !== + normalizeProjectPathForComparison(input.expectedWorkspaceRoot) + ) { + return yield* new AgentSessionImportProjectChangedError({ projectId: input.projectId }); + } + const completedSources = yield* snapshots + .getImportedAgentSessionSources(input.projectId) + .pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-projects", cause })), + ); + const threads = scanner.recentThreads( + workspaceRoot, + completedSources.map((entry) => entry.source), + ); + const importedThreadIds = new Set(); + let importedCount = 0; + let skippedCount = 0; + + yield* Stream.runForEach(threads, (outcome) => + Effect.gen(function* () { + if (outcome._tag === "Skipped") { + skippedCount += 1; + return; + } + if (outcome._tag === "AlreadyImported" || outcome._tag === "Duplicate") { + const threadId = ThreadId.make( + `import:${outcome.source.providerInstanceId}:${outcome.source.providerSessionId}`, + ); + if (outcome._tag === "AlreadyImported") { + importedThreadIds.add(threadId); + importedCount += 1; + } else if (importedThreadIds.has(threadId)) { + const recorded = yield* directory + .recordImportedTranscript({ threadId, source: outcome.source }) + .pipe(Effect.result); + if (recorded._tag === "Failure") { + skippedCount += 1; + yield* Effect.logWarning("Could not record an imported transcript copy", { + threadId, + cause: recorded.failure, + }); + } + } + return; + } + const thread = outcome.thread; + const threadId = ThreadId.make( + `import:${thread.providerInstanceId}:${thread.providerSessionId}`, + ); + const imported = yield* Effect.gen(function* () { + const provider = ProviderDriverKind.make(thread.source); + const model = thread.model ?? DEFAULT_MODEL_BY_PROVIDER[provider] ?? DEFAULT_MODEL; + const existingThread = yield* snapshots.getThreadDetailById(threadId); + const existingBinding = yield* directory.getBinding(threadId); + + if ( + thread.source === "claudeAgent" && + !CLAUDE_SESSION_ID_PATTERN.test(thread.providerSessionId) + ) { + return yield* new AgentSessionUnresumableSessionError({ + source: thread.source, + providerSessionId: thread.providerSessionId, + }); + } + + if (Option.isSome(existingThread) && existingThread.value.projectId !== input.projectId) { + return yield* new AgentSessionThreadProjectConflictError({ + threadId, + expectedProjectId: input.projectId, + actualProjectId: existingThread.value.projectId, + }); + } + + const importedHistoryPresent = Option.isSome(existingThread) + ? hasImportedHistory(existingThread.value) + : false; + if ( + Option.isSome(existingThread) && + importedHistoryPresent && + Option.isSome(existingBinding) + ) { + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + return true; + } + + if ( + Option.isSome(existingThread) && + hasImportBlockingActivity(existingThread.value, importedHistoryPresent) + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + if ( + Option.isSome(existingBinding) && + (existingBinding.value.provider !== provider || + existingBinding.value.providerInstanceId !== thread.providerInstanceId || + existingBinding.value.status !== "stopped") + ) { + return yield* new AgentSessionThreadModifiedError({ threadId }); + } + + // Install the cursor before the thread becomes visible. A concurrent + // real session can replace it, while insert-ignore keeps this import + // from replacing that newer binding. + if (Option.isNone(existingBinding)) { + yield* directory.upsert( + { + threadId, + provider, + providerInstanceId: thread.providerInstanceId, + status: "stopped", + runtimeMode: DEFAULT_RUNTIME_MODE, + resumeCursor: + thread.source === "codex" + ? { threadId: thread.providerSessionId } + : { threadId, resume: thread.providerSessionId }, + runtimePayload: { cwd: workspaceRoot }, + }, + { onConflict: "ignore" }, + ); + } + + if (Option.isNone(existingThread)) { + yield* engine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + projectId: input.projectId, + title: thread.title, + modelSelection: { instanceId: thread.providerInstanceId, model }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: null, + worktreePath: null, + createdAt: thread.createdAt, + historyImport: true, + }); + } + + if (!importedHistoryPresent) { + yield* engine.dispatch({ + type: "thread.history.import", + commandId: CommandId.make(yield* crypto.randomUUIDv4), + threadId, + messages: thread.messages.map((message, index) => ({ + messageId: MessageId.make(`${threadId}:${String(index).padStart(6, "0")}`), + role: message.role, + text: message.text, + createdAt: message.createdAt, + })), + }); + } + + yield* directory.recordImportedTranscript({ threadId, source: outcome.source }); + + return true; + }).pipe( + Effect.catch((cause) => + Effect.logWarning("Could not import an agent session", { + provider: thread.source, + sessionId: thread.providerSessionId, + cause, + }).pipe(Effect.as(false)), + ), + ); + + if (imported) { + importedThreadIds.add(threadId); + importedCount += 1; + } else { + skippedCount += 1; + } + }), + ); + + return { importedCount, skippedCount } satisfies AgentSessionImportResult; +}); diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000000..dc6d72a0ce63 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,3088 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as NodeOS from "node:os"; +import { describe, expect, it } from "@effect/vitest"; +import { + type OrchestrationProjectShell, + ProjectId, + ProviderDriverKind, + ProviderInstanceId, + type ServerSettings as ContractServerSettings, +} from "@t3tools/contracts"; +import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProjectShell = (workspaceRoot: string): OrchestrationProjectShell => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", +}); + +/** Only `getShellSnapshot` is exercised; the rest must not be called. */ +const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => + Effect.succeed({ + snapshotSequence: 0, + projects: importedWorkspaceRoots.map((workspaceRoot) => makeProjectShell(workspaceRoot)), + threads: [], + updatedAt: "2026-01-01T00:00:00.000Z", + }), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.succeed([]), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +interface ScannerTestInput { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; + readonly providerInstances?: ContractServerSettings["providerInstances"]; +} + +const makeScannerTestLayer = (input: ScannerTestInput) => + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + ...(input.providerInstances === undefined + ? {} + : { providerInstances: input.providerInstances }), + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ); + +const runScan = (input: ScannerTestInput) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreadOutcomes = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(input.workspaceRoot).pipe( + Stream.runCollect, + Effect.map((outcomes) => Array.from(outcomes)), + ); + }).pipe(Effect.provide(makeScannerTestLayer(input))); + +const runRecentThreads = (input: ScannerTestInput & { readonly workspaceRoot: string }) => + runRecentThreadOutcomes(input).pipe( + Effect.map((outcomes) => + outcomes.flatMap((outcome) => (outcome._tag === "Importable" ? [outcome.thread] : [])), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +const encodeTranscriptRecord = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); + +function makeRecordLimitTranscript(cwd: string, overflow: boolean): string { + const records = + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "record-limit-session", cwd }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "First prompt" }, + }), + ].join("\n") + + "\n" + + "{}\n".repeat(99_998); + return overflow + ? records + + "\n" + + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Overflow prompt" }, + }) + + "\n" + : records; +} + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "does not open a non-file %s transcript", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const transcriptPath = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects", "-slug", "session.jsonl") + : path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-session.jsonl"); + yield* fileSystem.makeDirectory(transcriptPath, { recursive: true }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(transcriptOpenCount).toBe(0); + }), + ); + + it.effect.each(["claudeAgent", "codex"] as const)( + "stops %s directory reads at the discovery operation budget", + (source) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const discoveryRoot = + source === "claudeAgent" + ? path.join(claudeHomePath, "projects") + : path.join(codexHomePath, "sessions"); + const emptyDirectories = Array.from( + { length: 20_001 }, + (_, index) => `empty-${index.toString().padStart(5, "0")}`, + ); + let directoryReadCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed(emptyDirectories); + } + if (path.dirname(directory) === discoveryRoot) { + directoryReadCount += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates).toEqual([]); + expect(directoryReadCount).toBe(20_000); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("returns the imported project ID through a realpath alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspace, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("matches a persisted project alias to a transcript realpath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const workspaceAlias = path.join(linkParent, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspaceAlias], + }); + + expect(result.candidates[0]).toMatchObject({ + path: workspaceAlias, + projectId: ProjectId.make("project-1"), + alreadyImported: true, + }); + }), + ); + + it.effect("merges case aliases and preserves the persisted project path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspaceAlias), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "01", "02", "rollout-b.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + projectId: ProjectId.make("project-1"), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("keeps case variants distinct when the filesystem identities differ", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const backingUpper = yield* makeTempDir("t3code-backing-upper-"); + const backingLower = yield* makeTempDir("t3code-backing-lower-"); + const aliasParent = yield* makeTempDir("t3code-case-aliases-"); + const upperWorkspace = path.join(aliasParent, "Repo"); + const lowerWorkspace = path.join(aliasParent, "repo"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-upper", "a.jsonl"), + contents: claudeSessionLine(upperWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-lower", "b.jsonl"), + contents: claudeSessionLine(lowerWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem.stat( + filePath === upperWorkspace + ? backingUpper + : filePath === lowerWorkspace + ? backingLower + : filePath, + ), + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + upperWorkspace, + lowerWorkspace, + ]); + }), + ); + + it.effect("uses explicit provider instance homes instead of overridden legacy homes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeInstanceHome = yield* makeTempDir("t3code-claude-instance-"); + const codexInstanceHome = yield* makeTempDir("t3code-codex-instance-"); + const legacyWorkspace = yield* makeTempDir("t3code-workspace-legacy-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-legacy", "session.jsonl"), + contents: claudeSessionLine(legacyWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeInstanceHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-02-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexInstanceHome, + "sessions", + "2026", + "03", + "01", + "rollout-instance.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: claudeInstanceHome }, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexInstanceHome }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("scans each distinct home across multiple instances once", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const otherCodexHome = yield* makeTempDir("t3code-codex-other-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + for (const [home, cwd] of [ + [codexHomePath, workspace], + [otherCodexHome, otherWorkspace], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "01", "01", "rollout-session.jsonl"), + contents: codexRolloutLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHomePath }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: otherCodexHome }, + }, + }, + }); + + expect(result.candidates).toHaveLength(2); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([1, 1]); + expect(result.candidates.map((candidate) => candidate.path).sort()).toEqual( + [workspace, otherWorkspace].sort(), + ); + }), + ); + + it.effect("honors provider instance home directory environment variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-legacy-"); + const codexHomePath = yield* makeTempDir("t3code-codex-legacy-"); + const claudeEnvironmentHome = yield* makeTempDir("t3code-claude-env-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const claudeWorkspace = yield* makeTempDir("t3code-workspace-claude-"); + const codexWorkspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join(claudeEnvironmentHome, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(claudeWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(codexWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + environment: [ + { name: "CLAUDE_CONFIG_DIR", value: claudeEnvironmentHome, sensitive: false }, + ], + config: {}, + }, + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + environment: [{ name: "CODEX_HOME", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + codexWorkspace, + claudeWorkspace, + ]); + }), + ); + + it.effect("ignores invalid provider instances while scanning the remaining providers", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-actual", "session.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: 123 }, + }, + }, + }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("does not scan provider instances disabled by the envelope or config", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const envelopeDisabledHome = yield* makeTempDir("t3code-codex-disabled-envelope-"); + const configDisabledHome = yield* makeTempDir("t3code-codex-disabled-config-"); + const envelopeWorkspace = yield* makeTempDir("t3code-workspace-disabled-envelope-"); + const configWorkspace = yield* makeTempDir("t3code-workspace-disabled-config-"); + + for (const [home, workspace, session] of [ + [envelopeDisabledHome, envelopeWorkspace, "envelope-disabled"], + [configDisabledHome, configWorkspace, "config-disabled"], + ] as const) { + yield* writeTranscript({ + filePath: path.join(home, "sessions", "2026", "08", "24", `rollout-${session}.jsonl`), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + } + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("codex-envelope-disabled")]: { + driver: ProviderDriverKind.make("codex"), + enabled: false, + config: { homePath: envelopeDisabledHome }, + }, + [ProviderInstanceId.make("codex-config-disabled")]: { + driver: ProviderDriverKind.make("codex"), + config: { enabled: false, homePath: configDisabledHome }, + }, + }, + }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("ignores relative working directories from malformed transcripts", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-relative", "session.jsonl"), + contents: claudeSessionLine(path.relative(path.resolve(), workspace)), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes the home directory, temporary root, and T3 data directory", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + for (const [index, cwd] of [ + NodeOS.homedir(), + NodeOS.tmpdir(), + configBaseDir, + workspace, + ].entries()) { + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", `-slug-${index}`, "session.jsonl"), + contents: claudeSessionLine(cwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") + index, + }); + } + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("excludes T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("finds the cwd on a later line when the first records carry none", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + // Claude transcripts often open with records that have no cwd. + const contents = `{"type":"file-history-snapshot","messageId":"m1"}\n{"type":"queue-operation","operation":"enqueue"}\n${claudeSessionLine(workspace)}`; + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("reads a complete transcript record at the exact chunk boundary", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const record = claudeSessionLine(workspace).split("\n")[0]!; + const prefix = '{"padding":"'; + const suffix = `",${record.slice(1)}`; + const contents = `${prefix}${"x".repeat(32 * 1024 - prefix.length - suffix.length)}${suffix}`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-exact", "session.jsonl"), + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(contents).toHaveLength(32 * 1024); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect("finds session metadata after a first record larger than one chunk", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const history = `{"type":"file-history-snapshot","data":"${"x".repeat(32 * 1024)}"}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-large", "session.jsonl"), + contents: `${history}${claudeSessionLine(workspace)}`, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + }), + ); + + it.effect.each([64, 65])("shares metadata bytes across homes for %s one-MiB files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-metadata-codex-"); + const firstWorkspace = yield* makeTempDir("t3code-metadata-first-project-"); + const secondWorkspace = yield* makeTempDir("t3code-metadata-second-project-"); + const directories = [ + path.join(claudeHomePath, "projects", "p"), + path.join(secondHome, "projects", "p"), + ]; + const templates = directories.map((directory) => path.join(directory, "template.jsonl")); + for (const [index, workspace] of [firstWorkspace, secondWorkspace].entries()) { + const record = encodeTranscriptRecord({ cwd: workspace }); + yield* writeTranscript({ + filePath: templates[index]!, + contents: + " ".repeat(1024 * 1024 - new TextEncoder().encode(record).byteLength) + record, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z") - index * 1_000, + }); + } + const resolveFile = (filePath: string) => { + const index = directories.indexOf(path.dirname(filePath)); + return index === -1 ? filePath : templates[index]!; + }; + let reservedBytes = 0; + let opens = 0; + const requests: number[] = []; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + const index = directories.indexOf(directory); + return index === -1 + ? fileSystem.readDirectory(directory, options) + : Effect.succeed( + Array.from( + { length: index === 0 ? 32 : count - 32 }, + (_, item) => `session-${item}.jsonl`, + ), + ); + }, + stat: (filePath) => fileSystem.stat(resolveFile(filePath)), + open: (filePath, options) => { + if (!directories.includes(path.dirname(filePath))) + return fileSystem.open(filePath, options); + opens += 1; + return fileSystem.open(resolveFile(filePath), options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => { + reservedBytes += Number(size); + requests.push(Number(size)); + return file.readAlloc(size); + }, + })), + ); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([ + firstWorkspace, + secondWorkspace, + ]); + expect(result.candidates.map((candidate) => candidate.threadCount)).toEqual([32, 32]); + expect(result.truncated).toBe(count === 65 ? true : undefined); + expect(opens).toBe(64); + expect(reservedBytes).toBe(64 * 1024 * 1024); + expect(requests[0]).toBe(8 * 1024); + expect(Math.max(...requests)).toBe(8 * 1024); + }), + ); + + it.effect.each([50, 51])("bounds metadata open/read calls for %s short-read files", (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-short-metadata-home-"); + const codexHomePath = yield* makeTempDir("t3code-short-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-short-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + const record = encodeTranscriptRecord({ cwd: workspace }); + const contents = " ".repeat(399 - new TextEncoder().encode(record).byteLength) + record; + const bytes = new TextEncoder().encode(contents); + yield* writeTranscript({ + filePath: template, + contents, + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let operations = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed( + Array.from({ length: count }, (_, index) => `session-${index}.jsonl`), + ) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + operations += 1; + let offset = 0; + return fileSystem.open(template, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: () => + Effect.sync(() => { + operations += 1; + if (offset === bytes.length) return Option.none(); + return Option.some(bytes.subarray(offset, ++offset)); + }), + })), + ); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(operations).toBe(20_000); + expect(result.candidates[0]?.threadCount).toBe(50); + expect(result.truncated).toBe(count === 51 ? true : undefined); + }), + ); + + it.effect("bounds malformed metadata records without excluding another account", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-record-metadata-home-"); + const secondHome = yield* makeTempDir("t3code-record-metadata-second-"); + const codexHomePath = yield* makeTempDir("t3code-record-metadata-codex-"); + const workspace = yield* makeTempDir("t3code-record-metadata-project-"); + const directory = path.join(claudeHomePath, "projects", "p"); + const template = path.join(directory, "template.jsonl"); + yield* writeTranscript({ + filePath: template, + contents: "x\n".repeat(1_001), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(secondHome, "projects", "p", "session.jsonl"), + contents: encodeTranscriptRecord({ cwd: workspace }), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + let malformedOpens = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (target, options) => + target === directory + ? Effect.succeed(Array.from({ length: 102 }, (_, index) => `session-${index}.jsonl`)) + : fileSystem.readDirectory(target, options), + stat: (filePath) => + fileSystem.stat(path.dirname(filePath) === directory ? template : filePath), + open: (filePath, options) => { + if (path.dirname(filePath) !== directory) return fileSystem.open(filePath, options); + malformedOpens += 1; + return fileSystem.open(template, options); + }, + }); + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: secondHome }, + }, + }, + }).pipe(Effect.provideService(FileSystem.FileSystem, observedFileSystem)); + expect(result.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + expect(malformedOpens).toBe(100); + expect(result.truncated).toBe(true); + }), + ); + + it.effect.each([19_999, 20_000])( + "reports unfinished directory work for %s project directories", + (count) => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const claudeHomePath = yield* makeTempDir("t3code-directory-budget-home-"); + const codexHomePath = yield* makeTempDir("t3code-directory-budget-codex-"); + const projectsDir = path.join(claudeHomePath, "projects"); + let reads = 0; + const observedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => { + if (directory === projectsDir) { + reads += 1; + return Effect.succeed( + Array.from({ length: count }, (_, index) => `project-${index}`), + ); + } + if (path.dirname(directory) === projectsDir) { + reads += 1; + return Effect.succeed([]); + } + return fileSystem.readDirectory(directory, options); + }, + }); + const result = yield* runScan({ claudeHomePath, codexHomePath }).pipe( + Effect.provideService(FileSystem.FileSystem, observedFileSystem), + ); + expect(reads).toBe(20_000); + expect(result.candidates).toEqual([]); + expect(result.truncated).toBe(count === 20_000 ? true : undefined); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); + + describe("recentThreads", () => { + it.effect.each([false, true])( + "counts terminal newlines correctly with record overflow=%s", + (overflow) => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-limit-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-limit-codex-"); + const workspace = yield* makeTempDir("t3code-record-limit-project-"); + const directory = path.join(codexHomePath, "sessions", "2026", "08", "24"); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-records.jsonl"), + contents: makeRecordLimitTranscript(workspace, overflow), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(directory, "rollout-older.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "older-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Older prompt" }, + }), + ].join("\n"), + mtimeMs: nowMs - 1_000, + }); + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual( + overflow ? ["Skipped", "Importable"] : ["Importable", "Skipped"], + ); + expect( + outcomes.flatMap((outcome) => + outcome._tag === "Importable" + ? outcome.thread.messages.map((message) => message.text) + : [], + ), + ).toEqual([overflow ? "Older prompt" : "First prompt"]); + }), + ); + + it.effect("imports recent Claude and Codex sessions for the selected project only", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const claudeTranscript = (cwd: string, sessionId: string) => + `${JSON.stringify({ + type: "user", + cwd, + sessionId, + timestamp: "2026-08-23T12:00:00.000Z", + message: { role: "user", content: "Fix the project" }, + })}\n${JSON.stringify({ + type: "assistant", + sessionId, + timestamp: "2026-08-23T12:01:00.000Z", + message: { role: "assistant", content: [{ type: "text", text: "Done" }] }, + })}\n`; + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-recent.jsonl"), + contents: claudeTranscript(workspace, "claude-recent"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-selected", "claude-old.jsonl"), + contents: claudeTranscript(workspace, "claude-old"), + mtimeMs: nowMs - 31 * 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-other", "claude-other.jsonl"), + contents: claudeTranscript(otherWorkspace, "claude-other"), + mtimeMs: nowMs - 24 * 60 * 60 * 1000, + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-codex-recent.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "codex-recent", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { type: "user_message", message: "Review this code" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:01:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Looks good" }], + }, + }), + ].join("\n"), + mtimeMs: nowMs - 60 * 60 * 1000, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual([ + "codex-recent", + "claude-recent", + ]); + expect(threads.map((thread) => thread.messages.map((message) => message.text))).toEqual([ + ["Review this code", "Looks good"], + ["Fix the project", "Done"], + ]); + }), + ); + + it.effect("imports history recorded with a case alias", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const workspaceAlias = path.join( + path.dirname(workspace), + path.basename(workspace).toUpperCase(), + ); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-alias", "case-session.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "user", + cwd: workspaceAlias, + sessionId: "case-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: "Import case alias history" }, + }), + encodeTranscriptRecord({ + type: "assistant", + sessionId: "case-session", + timestamp: "2026-08-24T10:01:00.000Z", + message: { role: "assistant", content: "Imported" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => fileSystem.stat(filePath === workspaceAlias ? workspace : filePath), + }); + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(threads.map((thread) => thread.providerSessionId)).toEqual(["case-session"]); + }), + ); + + it.effect("keeps the provider instance that owns a custom session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const customHome = yield* makeTempDir("t3code-codex-custom-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(customHome, "sessions", "2026", "08", "24", "rollout-custom.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "custom-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use my work account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: customHome }, + }, + }, + }); + + expect(threads[0]?.providerInstanceId).toBe("codex-work"); + }), + ); + + it.effect("suppresses duplicate session copies without reporting a skipped import", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "copied-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session once" }, + }), + ].join("\n"); + + for (const [name, mtimeMs] of [ + ["rollout-copy-a.jsonl", nowMs], + ["rollout-copy-b.jsonl", nowMs - 1], + ] as const) { + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", name), + contents, + mtimeMs, + }); + } + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Importable", "Duplicate"]); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "copied-session" }, + }); + }), + ); + + it.effect("shares a 64 MiB full-read budget across providers without hiding projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-budget-codex-"); + const workspace = yield* makeTempDir("t3code-budget-workspace-"); + const transcriptPaths = new Set(); + for (const [index, source] of [ + "codex", + "claudeAgent", + "codex", + "claudeAgent", + "codex", + ].entries()) { + const sessionId = `budget-session-${index}`; + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ) + : path.join(claudeHomePath, "projects", "selected", `${sessionId}.jsonl`); + const contents = + source === "codex" + ? [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + : encodeTranscriptRecord({ + type: "user", + cwd: workspace, + sessionId, + message: { content: "Imported prompt" }, + }); + transcriptPaths.add(filePath); + yield* writeTranscript({ + filePath, + contents: `${contents}\n`.padEnd(16 * 1024 * 1024, " "), + mtimeMs: nowMs - index * 1_000, + }); + } + + const opens = new Map(); + let fullReadBytes = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + const count = (opens.get(filePath) ?? 0) + 1; + opens.set(filePath, count); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => + !transcriptPaths.has(filePath) || count === 1 + ? file + : { + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.sync(() => { + if (chunk._tag === "Some") fullReadBytes += chunk.value.byteLength; + }), + ), + ), + }, + ), + ); + }, + }); + const outcomes = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates[0]?.threadCount).toBe(5); + return yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + expect(outcomes.map((outcome) => outcome._tag)).toEqual([ + "Importable", + "Importable", + "Importable", + "Importable", + "Skipped", + ]); + expect(fullReadBytes).toBe(64 * 1024 * 1024); + }), + ); + + it.effect("skips excessive records without blocking an older valid transcript", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-record-budget-claude-"); + const codexHomePath = yield* makeTempDir("t3code-record-budget-codex-"); + const workspace = yield* makeTempDir("t3code-record-budget-workspace-"); + for (const [sessionId, padding, mtimeMs] of [ + ["excessive", "\n".repeat(100_001), nowMs], + ["older", "", nowMs - 1_000], + ] as const) { + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + `rollout-${sessionId}.jsonl`, + ), + contents: + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n") + padding, + mtimeMs, + }); + } + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + expect(outcomes.map((outcome) => outcome._tag)).toEqual(["Skipped", "Importable"]); + expect(outcomes[1]).toMatchObject({ thread: { providerSessionId: "older" } }); + }), + ); + + for (const source of ["claudeAgent", "codex"] as const) { + for (const replacement of [ + "same root", + "other root", + "symlink alias", + "other then same", + ] as const) { + it.effect.skipIf(replacement === "symlink alias" && !symlinksSupported)( + `rechecks ${source} snapshot cwd after replacement with ${replacement}`, + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const fixture = yield* makeTempDir("t3code-replaced-cwd-"); + const workspace = path.join(fixture, "original"); + const otherWorkspace = path.join(fixture, "other"); + const alias = path.join(fixture, "alias"); + const claudeHomePath = path.join(fixture, "claude"); + const codexHomePath = path.join(fixture, "codex"); + yield* fileSystem.makeDirectory(workspace); + yield* fileSystem.makeDirectory(otherWorkspace); + if (replacement === "symlink alias") yield* fileSystem.symlink(workspace, alias); + const filePath = + source === "codex" + ? path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ) + : path.join(claudeHomePath, "projects", "p", "replaced.jsonl"); + const makeContents = (cwd: string, text: string, laterCwd?: string) => + [ + ...(source === "codex" + ? [ + { type: "session_meta", payload: { id: "replacement-session", cwd } }, + { type: "event_msg", payload: { type: "user_message", message: text } }, + ] + : [ + { + type: "user", + cwd, + sessionId: "replacement-session", + message: { content: text }, + }, + ]), + ...(laterCwd === undefined ? [] : [{ cwd: laterCwd }]), + ] + .map((record) => encodeTranscriptRecord(record)) + .join("\n"); + yield* writeTranscript({ + filePath, + contents: makeContents(workspace, "Original prompt"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.candidates.map((candidate) => candidate.path)).toEqual([workspace]); + const replacementCwd = + replacement === "symlink alias" + ? alias + : replacement === "same root" + ? workspace + : otherWorkspace; + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: makeContents( + replacementCwd, + "Replacement prompt", + replacement === "other then same" ? workspace : undefined, + ), + mtimeMs: nowMs, + }); + const outcomes = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + if (replacement === "same root" || replacement === "symlink alias") { + expect(outcomes).toHaveLength(1); + expect(outcomes[0]).toMatchObject({ + _tag: "Importable", + thread: { messages: [{ text: "Replacement prompt" }] }, + }); + } else { + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + } + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + } + } + + it.effect("checks file identity and provider before skipping completed history", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-completed-claude-"); + const codexHomePath = yield* makeTempDir("t3code-completed-codex-"); + const workspace = yield* makeTempDir("t3code-completed-workspace-"); + const filePath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-replaced.jsonl", + ); + const contents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Imported prompt" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath, + contents: contents("original-session"), + mtimeMs: nowMs, + }); + + yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const initial = yield* scanner.recentThreads(workspace).pipe(Stream.runCollect); + const imported = initial[0]; + expect(imported?._tag).toBe("Importable"); + if (imported?._tag !== "Importable") return; + const completed = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(completed[0]?._tag).toBe("AlreadyImported"); + const wrongProvider = yield* scanner + .recentThreads(workspace, [{ ...imported.source, provider: "claudeAgent" }]) + .pipe(Stream.runCollect); + expect(wrongProvider[0]?._tag).toBe("Importable"); + + // Keep the old inode allocated while replacing the path with an equal-size file. + yield* fileSystem.open(filePath); + yield* fileSystem.remove(filePath); + yield* writeTranscript({ + filePath, + contents: contents("replaced-session"), + mtimeMs: nowMs, + }); + const replaced = yield* scanner + .recentThreads(workspace, [imported.source]) + .pipe(Stream.runCollect); + expect(replaced[0]).toMatchObject({ + _tag: "Importable", + thread: { providerSessionId: "replaced-session" }, + source: { size: imported.source.size, mtimeMs: imported.source.mtimeMs }, + }); + }).pipe(Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath }))); + }), + ); + + it.effect("reports an eligible transcript over 16 MiB as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcript = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "large-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this large session" }, + }), + ] + .join("\n") + .padEnd(16 * 1024 * 1024 + 1, " "); + yield* writeTranscript({ + filePath: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-large.jsonl"), + contents: transcript, + mtimeMs: nowMs, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("reports stat, read, and parse failures as skipped", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const missingPath = path.join(codexHomePath, "missing.jsonl"); + const transcriptPaths = { + stat: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-stat.jsonl"), + read: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-read.jsonl"), + parse: path.join(codexHomePath, "sessions", "2026", "08", "24", "rollout-parse.jsonl"), + }; + const transcriptContents = (sessionId: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Import this session" }, + }), + ].join("\n"); + + yield* writeTranscript({ + filePath: transcriptPaths.stat, + contents: transcriptContents("stat-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.read, + contents: transcriptContents("read-session"), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: transcriptPaths.parse, + contents: encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parse-session", cwd: workspace }, + }), + mtimeMs: nowMs, + }); + + let statCount = 0; + let readOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPaths.stat) return fileSystem.stat(filePath); + statCount += 1; + return fileSystem.stat(statCount === 1 ? filePath : missingPath); + }, + open: (filePath, options) => { + if (filePath !== transcriptPaths.read) return fileSystem.open(filePath, options); + readOpenCount += 1; + return fileSystem.open(readOpenCount === 1 ? filePath : missingPath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(outcomes).toEqual([{ _tag: "Skipped" }, { _tag: "Skipped" }, { _tag: "Skipped" }]); + }), + ); + + it.effect("does not reopen a transcript that becomes a non-file after discovery", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const nonFilePath = yield* makeTempDir("t3code-non-file-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-changed.jsonl", + ); + yield* writeTranscript({ + filePath: transcriptPath, + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "changed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + let transcriptStatCount = 0; + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => { + if (filePath !== transcriptPath) return fileSystem.stat(filePath); + transcriptStatCount += 1; + return fileSystem.stat(transcriptStatCount === 1 ? transcriptPath : nonFilePath); + }, + open: (filePath, options) => { + if (filePath === transcriptPath) transcriptOpenCount += 1; + return fileSystem.open(filePath, options); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptStatCount).toBe(2); + expect(transcriptOpenCount).toBe(1); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not import a transcript dated after the current time", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-future.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "future-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Future work" }, + }), + ].join("\n"), + mtimeMs: nowMs + 1, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }); + + expect(outcomes).toEqual([]); + }), + ); + + it.effect("skips growth during reading without exceeding the reserved bytes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-growing.jsonl", + ); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "growing-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ filePath: transcriptPath, contents, mtimeMs: nowMs }); + let transcriptOpenCount = 0; + let fullReadBytes = 0; + let grew = false; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + if (transcriptOpenCount === 1) return fileSystem.open(filePath, options); + return fileSystem.open(filePath, options).pipe( + Effect.map((file) => ({ + ...file, + stat: file.stat, + readAlloc: (size: FileSystem.SizeInput) => + file.readAlloc(size).pipe( + Effect.tap((chunk) => + Effect.gen(function* () { + if (chunk._tag === "None") return; + fullReadBytes += chunk.value.byteLength; + if (!grew) { + grew = true; + yield* fileSystem.writeFileString(filePath, `${contents}\nchanged`); + } + }), + ), + ), + })), + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(fullReadBytes).toBe(new TextEncoder().encode(contents).byteLength); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("skips a transcript that shrinks after its size check", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const transcriptPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-shrinking.jsonl", + ); + const shrunkPath = path.join(codexHomePath, "shrunk.jsonl"); + const contents = [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shrinking-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import a changing file" }, + }), + ].join("\n"); + yield* writeTranscript({ + filePath: transcriptPath, + contents: `${contents}\n${"padding".repeat(100)}`, + mtimeMs: nowMs, + }); + yield* writeTranscript({ filePath: shrunkPath, contents, mtimeMs: nowMs }); + + let transcriptOpenCount = 0; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (filePath !== transcriptPath) return fileSystem.open(filePath, options); + transcriptOpenCount += 1; + return fileSystem.open( + transcriptOpenCount === 1 ? transcriptPath : shrunkPath, + options, + ); + }, + }); + + const outcomes = yield* runRecentThreadOutcomes({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + }).pipe(Effect.provideService(FileSystem.FileSystem, simulatedFileSystem)); + + expect(transcriptOpenCount).toBe(2); + expect(outcomes).toEqual([{ _tag: "Skipped" }]); + }), + ); + + it.effect("does not read the second transcript when the consumer takes one thread", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const makeCodexTranscript = (sessionId: string, text: string) => + [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: sessionId, cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: text }, + }), + ].join("\n"); + const olderPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "23", + "rollout-older.jsonl", + ); + const newerPath = path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-newer.jsonl", + ); + yield* writeTranscript({ + filePath: olderPath, + contents: makeCodexTranscript("older-session", "Older prompt"), + mtimeMs: nowMs - 1_000, + }); + yield* writeTranscript({ + filePath: newerPath, + contents: makeCodexTranscript("newer-session", "Newer prompt"), + mtimeMs: nowMs, + }); + + const openCounts = new Map(); + const contentReads: Array = []; + const trackedPaths = new Set([olderPath, newerPath]); + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (filePath, options) => { + if (trackedPaths.has(filePath)) { + const count = (openCounts.get(filePath) ?? 0) + 1; + openCounts.set(filePath, count); + if (count === 2) contentReads.push(filePath); + } + return fileSystem.open(filePath, options); + }, + }); + + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.recentThreads(workspace).pipe( + Stream.take(1), + Stream.runCollect, + Effect.map((items) => Array.from(items)), + ); + }).pipe( + Effect.provide(makeScannerTestLayer({ claudeHomePath, codexHomePath })), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["newer-session"]); + expect(contentReads).toEqual([newerPath]); + expect(openCounts.get(olderPath)).toBe(1); + }), + ); + + it.effect("does not import sessions from a T3-managed worktree", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const workspace = path.join(configBaseDir, "worktrees", "t3code", "managed-worktree"); + yield* fileSystem.makeDirectory(workspace, { recursive: true }); + + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "08", + "24", + "rollout-managed.jsonl", + ), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "managed-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Do not import this session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + configBaseDir, + workspaceRoot: workspace, + }); + + expect(threads).toEqual([]); + }), + ); + + it.effect("uses one deterministic provider instance for a shared session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the shared session" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex"]); + }), + ); + + it.effect("uses configured order when custom instances share a session home", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const sharedHome = yield* makeTempDir("t3code-codex-shared-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(sharedHome, "sessions", "2026", "08", "24", "rollout-shared.jsonl"), + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "shared-session", cwd: workspace }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Use the first account" }, + }), + ].join("\n"), + mtimeMs: nowMs, + }); + + const threads = yield* runRecentThreads({ + claudeHomePath, + codexHomePath, + workspaceRoot: workspace, + providerInstances: { + [ProviderInstanceId.make("codex-work")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + [ProviderInstanceId.make("codex-personal")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: sharedHome }, + }, + }, + }); + + expect(threads.map((thread) => thread.providerInstanceId)).toEqual(["codex-work"]); + }), + ); + + it.effect("keeps a second account when the first has 5000 newer files", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const nowMs = Date.parse("2026-08-24T12:00:00.000Z"); + yield* TestClock.setTime(nowMs); + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const oldWorkspace = yield* makeTempDir("t3code-workspace-old-"); + const recentWorkspace = yield* makeTempDir("t3code-workspace-recent-"); + const recentHome = yield* makeTempDir("t3code-claude-recent-home-"); + const oldDirectory = path.join(claudeHomePath, "projects", "-aaa-old"); + const oldTranscript = path.join(oldDirectory, "old.jsonl"); + const recentDirectory = path.join(recentHome, "projects", "-zzz-recent"); + + yield* writeTranscript({ + filePath: oldTranscript, + contents: encodeTranscriptRecord({ + type: "user", + cwd: oldWorkspace, + sessionId: "old-session", + message: { role: "user", content: "Old work" }, + }), + mtimeMs: nowMs, + }); + yield* writeTranscript({ + filePath: path.join(recentDirectory, "recent.jsonl"), + contents: encodeTranscriptRecord({ + type: "user", + cwd: recentWorkspace, + sessionId: "recent-session", + message: { role: "user", content: "Recent work" }, + }), + mtimeMs: nowMs - 1_000, + }); + + const simulatedOldTranscripts = Array.from( + { length: 5_000 }, + (_, index) => `old-${index}.jsonl`, + ); + const resolveTranscript = (filePath: string) => + path.dirname(filePath) === oldDirectory && path.basename(filePath).startsWith("old-") + ? oldTranscript + : filePath; + const simulatedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + readDirectory: (directory, options) => + directory === oldDirectory + ? Effect.succeed(simulatedOldTranscripts) + : fileSystem.readDirectory(directory, options), + stat: (filePath) => fileSystem.stat(resolveTranscript(filePath)), + open: (filePath, options) => fileSystem.open(resolveTranscript(filePath), options), + }); + + const input = { + claudeHomePath, + codexHomePath, + providerInstances: { + [ProviderInstanceId.make("claude-work")]: { + driver: ProviderDriverKind.make("claudeAgent"), + config: { homePath: recentHome }, + }, + }, + }; + const threads = yield* Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + const scan = yield* scanner.scan; + expect(scan.truncated).toBe(true); + return yield* scanner.recentThreads(recentWorkspace).pipe(Stream.runCollect); + }).pipe( + Effect.provide(makeScannerTestLayer(input)), + Effect.provideService(FileSystem.FileSystem, simulatedFileSystem), + ); + + expect( + threads.flatMap((outcome) => + outcome._tag === "Importable" ? [outcome.thread.providerSessionId] : [], + ), + ).toEqual(["recent-session"]); + }), + ); + }); +}); + +describe("parseAgentSessionTranscript", () => { + it.each([false, true])( + "handles the exact record limit and an interior blank overflow=%s", + (overflow) => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: makeRecordLimitTranscript("/project", overflow), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "unused", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + if (overflow) expect(thread).toBeNull(); + else expect(thread?.messages.map((message) => message.text)).toEqual(["First prompt"]); + }, + ); + + it("keeps Claude text and titles while dropping malformed and tool records", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + "not valid json", + JSON.stringify({ type: "ai-title", aiTitle: "Fix authentication" }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isMeta: true, + message: { role: "user", content: "Injected skill instructions" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + isCompactSummary: true, + message: { role: "user", content: "Injected compaction summary" }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + timestamp: "2026-08-24T10:00:00.000Z", + message: { role: "user", content: [{ type: "text", text: "Fix authentication" }] }, + }), + JSON.stringify({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: [{ type: "tool_result", text: "hidden" }] }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "claude-sonnet-5", + content: [{ type: "text", text: "Updated the login flow" }], + }, + }), + JSON.stringify({ + type: "assistant", + sessionId: "claude-session", + message: { + role: "assistant", + model: "", + content: [{ type: "text", text: "The provider request failed" }], + }, + }), + ].join("\n"), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toMatchObject({ + providerSessionId: "claude-session", + title: "Fix authentication", + model: "claude-sonnet-5", + messages: [ + { role: "user", text: "Fix authentication" }, + { role: "assistant", text: "Updated the login flow" }, + { role: "assistant", text: "The provider request failed" }, + ], + }); + }); + + it("drops injected Codex instructions while keeping the visible user event", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + JSON.stringify({ type: "session_meta", payload: { id: "codex-session" } }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\nInternal setup instructions\n", + }, + ], + }, + }), + JSON.stringify({ + type: "event_msg", + payload: { type: "user_message", message: "Fix the actual bug" }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Fix the actual bug" }], + }, + }), + JSON.stringify({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Fixed" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Fix the actual bug", + "Fixed", + ]); + }); + + it("keeps the canonical first prompt after long Codex transcripts are capped", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const laterAssistantMessages = Array.from({ length: 200 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...laterAssistantMessages, + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + }); + + it("restores the canonical first prompt when a later user message remains", () => { + const canonicalPrompt = "\n Keep the canonical prompt \n"; + const canonicalTimestamp = "2026-08-24T10:01:00.000Z"; + const assistantMessages = Array.from({ length: 198 }, (_, index) => + encodeTranscriptRecord({ + type: "response_item", + timestamp: `2026-08-24T11:${String(index % 60).padStart(2, "0")}:00.000Z`, + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: `Assistant message ${index}` }], + }, + }), + ); + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T10:00:00.000Z", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [{ type: "input_text", text: "Keep the canonical prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + timestamp: canonicalTimestamp, + payload: { type: "user_message", message: canonicalPrompt }, + }), + ...assistantMessages, + encodeTranscriptRecord({ + type: "event_msg", + timestamp: "2026-08-24T11:58:30.000Z", + payload: { type: "user_message", message: "Keep this later prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + timestamp: "2026-08-24T11:59:00.000Z", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Keep this latest response" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]).toMatchObject({ + role: "user", + text: canonicalPrompt, + createdAt: canonicalTimestamp, + }); + expect( + thread?.messages.filter((message) => message.text.trim() === canonicalPrompt.trim()), + ).toHaveLength(1); + expect(thread?.messages.some((message) => message.text === "Keep this later prompt")).toBe( + true, + ); + expect(thread?.messages.at(-1)?.text).toBe("Keep this latest response"); + }); + + it("keeps mixed-format response users when turn IDs repeat after an assistant", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-older" }, + content: [{ type: "input_text", text: "Keep this older prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Keep this newer prompt" }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Ask again when needed" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-newer" }, + content: [{ type: "input_text", text: "Keep this newer prompt" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this older prompt", + "Keep this newer prompt", + "Ask again when needed", + "Keep this newer prompt", + ]); + }); + + it("preserves response user text when Codex turn metadata is ambiguous", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: ["unexpected"], + content: [{ type: "input_text", text: "Keep this legacy prompt" }], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: " " }, + content: [{ type: "input_text", text: "Keep this prompt with a blank turn ID" }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Keep this legacy prompt", + "Keep this prompt with a blank turn ID", + ]); + }); + + it("uses the first valid Codex session ID when a fork copies ancestor metadata", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "fork-session", forked_from_id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "session_meta", + payload: { id: "parent-session" }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "Continue in the fork" }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.providerSessionId).toBe("fork-session"); + }); + + it("skips Codex transcripts without a resumable session ID", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: "This transcript has no session metadata" }, + }), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "rollout-2026-08-24T12-00-00-not-a-session-id", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("uses the canonical Codex event when its turn has generated response context", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "\n/tmp/project\nzsh\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "# AGENTS.md instructions for /tmp/project\n\n\nPrivate project rules\n", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: "Do something here so it looks like a real project.", + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + internal_chat_message_metadata_passthrough: { turn_id: "turn-1" }, + content: [ + { + type: "input_text", + text: "Do something here so it looks like a real project.", + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Created the project." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("Do something here so it looks like a real project."); + expect(thread?.messages.map((message) => message.text)).toEqual([ + "Do something here so it looks like a real project.", + "Created the project.", + ]); + }); + + it("preserves context markup in response-only Codex messages", () => { + const context = "\n/tmp/project\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [ + { + type: "input_text", + text: context, + }, + ], + }, + }), + encodeTranscriptRecord({ + type: "response_item", + payload: { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Initialize Git and add a README." }], + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([ + context, + "Initialize Git and add a README.", + ]); + }); + + it("preserves a canonical Codex event that starts with context markup", () => { + const prompt = + "\n/tmp/project\n\n\nCreate a useful project."; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe(""); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("preserves a Codex request heading in a canonical event", () => { + const prompt = "\n ## My request for Codex:\n\nFix the visible bug"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { + type: "user_message", + message: prompt, + }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.title).toBe("## My request for Codex:"); + expect(thread?.messages.map((message) => message.text)).toEqual([prompt]); + }); + + it("keeps context markup quoted inside visible Codex user text", () => { + const quoted = + "Do not remove this example:\n\n/tmp/example\n"; + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: [ + encodeTranscriptRecord({ type: "session_meta", payload: { id: "codex-session" } }), + encodeTranscriptRecord({ + type: "event_msg", + payload: { type: "user_message", message: quoted }, + }), + ].join("\n"), + source: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-25T08:00:00.000Z"), + }); + + expect(thread?.messages.map((message) => message.text)).toEqual([quoted]); + }); + + it("skips sessions without a visible user message", () => { + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: JSON.stringify({ + type: "assistant", + message: { role: "assistant", content: "Done" }, + }), + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "claude-session", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread).toBeNull(); + }); + + it("keeps the first prompt when later assistant output exceeds the message limit", () => { + const transcript = [ + encodeTranscriptRecord({ + type: "user", + sessionId: "claude-session", + message: { role: "user", content: "Keep this prompt" }, + }), + ...Array.from({ length: 250 }, (_, index) => + encodeTranscriptRecord({ + type: "assistant", + message: { role: "assistant", content: `Assistant update ${index}` }, + }), + ), + ].join("\n"); + + const thread = AgentSessionScanner.parseAgentSessionTranscript({ + contents: transcript, + source: "claudeAgent", + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + fallbackSessionId: "fallback", + lastActiveAtMs: Date.parse("2026-08-24T12:00:00.000Z"), + }); + + expect(thread?.messages).toHaveLength(200); + expect(thread?.messages[0]?.text).toBe("Keep this prompt"); + expect(thread?.messages.at(-1)?.text).toBe("Assistant update 249"); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000000..bc6d093d42a1 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,1313 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + ClaudeSettings, + CodexSettings, + ProviderDriverKind, + ProviderInstanceId, + resolveProviderInstanceEnabled, + type AgentSessionImportSource, + type AgentSessionProjectCandidate, + type AgentSessionScanResult, + type ProviderInstanceConfig, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** Chunk size for full transcript reads. */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; +/** Small reads avoid wasting the metadata budget on long Codex instruction headers. */ +const METADATA_READ_BYTES = 8 * 1024; +/** Prevent malformed transcripts from turning project discovery into a full file scan. */ +const MAX_TRANSCRIPT_SCAN_BYTES = 1024 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on discovery filesystem operations per source. Newest-first + * ordering needs mtimes before the read cap can be applied, so directory reads + * and candidate stats share a larger budget. Once it runs out the scan stops. + */ +const MAX_DISCOVERY_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_BYTES_PER_SOURCE = 64 * 1024 * 1024; +const MAX_METADATA_OPERATIONS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; +const MAX_METADATA_RECORDS_PER_SOURCE = 100_000; +const MAX_METADATA_RECORDS_PER_TRANSCRIPT = 1_000; +const RECENT_THREAD_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; +const MAX_IMPORTED_TRANSCRIPT_BYTES = 16 * 1024 * 1024; +const MAX_IMPORTED_MESSAGES = 200; +const MAX_IMPORT_BYTES = 64 * 1024 * 1024; +const MAX_IMPORT_TRANSCRIPTS = 100; +const MAX_IMPORT_RECORDS = 100_000; + +const TranscriptContentBlock = Schema.Struct({ + type: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), +}); + +const TranscriptMessage = Schema.Struct({ + role: Schema.optional(Schema.String), + content: Schema.optional(Schema.Union([Schema.String, Schema.Array(TranscriptContentBlock)])), + model: Schema.optional(Schema.String), +}); + +const CodexTurnMetadata = Schema.Struct({ + turn_id: Schema.optional(Schema.Union([Schema.String, Schema.Null])), +}); + +const TranscriptRecord = Schema.Struct({ + type: Schema.optional(Schema.String), + timestamp: Schema.optional(Schema.String), + sessionId: Schema.optional(Schema.String), + aiTitle: Schema.optional(Schema.String), + isSidechain: Schema.optional(Schema.Boolean), + isMeta: Schema.optional(Schema.Boolean), + isCompactSummary: Schema.optional(Schema.Boolean), + message: Schema.optional(TranscriptMessage), + payload: Schema.optional( + Schema.Struct({ + id: Schema.optional(Schema.String), + session_id: Schema.optional(Schema.String), + type: Schema.optional(Schema.String), + role: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), + model: Schema.optional(Schema.String), + content: Schema.optional(Schema.Array(TranscriptContentBlock)), + internal_chat_message_metadata_passthrough: Schema.optional(Schema.Unknown), + }), + ), +}); + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const decodeTranscriptRecord = Schema.decodeUnknownOption(Schema.fromJsonString(TranscriptRecord)); +const decodeCodexTurnMetadata = Schema.decodeUnknownOption(CodexTurnMetadata); + +export interface AgentSessionThreadMessage { + readonly role: "user" | "assistant"; + readonly text: string; + readonly createdAt: string; +} + +export interface AgentSessionThread { + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly providerSessionId: string; + readonly title: string; + readonly model: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly messages: ReadonlyArray; +} + +export type AgentSessionRecentThread = + | { + readonly _tag: "Importable"; + readonly thread: AgentSessionThread; + readonly source: AgentSessionImportSource; + } + | { readonly _tag: "AlreadyImported"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Duplicate"; readonly source: AgentSessionImportSource } + | { readonly _tag: "Skipped" }; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + readonly recentThreads: ( + workspaceRoot: string, + completedSources?: ReadonlyArray, + ) => Stream.Stream; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; + readonly transcripts: ReadonlyArray<{ + readonly filePath: string; + readonly mtimeMs: number | null; + }>; +} + +interface TranscriptCandidate { + readonly filePath: string; + readonly mtimeMs: number; + readonly providerInstanceId: ProviderInstanceId; + readonly size: number; +} + +interface MetadataReadBudget { + bytesRemaining: number; + operationsRemaining: number; + recordsRemaining: number; + truncated: boolean; +} + +function selectMetadataTranscripts(transcripts: ReadonlyArray) { + const selected: Array = []; + let pending = Array.from( + Map.groupBy(transcripts, (transcript) => transcript.providerInstanceId).values(), + (entries) => entries.values(), + ); + while (pending.length > 0 && selected.length < MAX_TRANSCRIPTS_PER_SOURCE) { + const nextRound: typeof pending = []; + for (const iterator of pending) { + if (selected.length === MAX_TRANSCRIPTS_PER_SOURCE) break; + const next = iterator.next(); + if (next.done) continue; + selected.push(next.value); + nextRound.push(iterator); + } + pending = nextRound; + } + return selected; +} + +function splitTranscriptRecords(contents: string, limit: number): string[] { + const records = contents.endsWith("\n") ? contents.slice(0, -1) : contents; + return records.split("\n", limit); +} + +function extractText( + content: string | ReadonlyArray | undefined, +): string { + if (typeof content === "string") return content.trim(); + if (content === undefined) return ""; + return content + .filter( + (block) => + block.type === "text" || block.type === "input_text" || block.type === "output_text", + ) + .map((block) => block.text?.trim() ?? "") + .filter((text) => text.length > 0) + .join("\n"); +} + +function normalizeTimestamp(value: string | undefined, fallback: string): string { + if (value === undefined) return fallback; + const parsed = DateTime.make(value); + return Option.isSome(parsed) ? DateTime.formatIso(parsed.value) : fallback; +} + +function codexTurnId(metadata: unknown): string | null { + const decoded = decodeCodexTurnMetadata(metadata); + if ( + Option.isNone(decoded) || + typeof decoded.value.turn_id !== "string" || + decoded.value.turn_id.trim().length === 0 + ) { + return null; + } + return decoded.value.turn_id; +} + +/** Keep visible user and assistant text while ignoring tools, reasoning, and malformed records. */ +export function parseAgentSessionTranscript( + input: { + readonly contents: string; + readonly source: AgentSessionSource; + readonly providerInstanceId: ProviderInstanceId; + readonly fallbackSessionId: string; + readonly lastActiveAtMs: number; + }, + lines = splitTranscriptRecords(input.contents, MAX_IMPORT_RECORDS + 1), +): AgentSessionThread | null { + if (lines.length > MAX_IMPORT_RECORDS) return null; + const fallbackTimestamp = DateTime.formatIso(DateTime.makeUnsafe(input.lastActiveAtMs)); + // Claude filenames are session IDs. Codex rollout filenames include extra + // timestamp text, so only transcript metadata can provide a resumable ID. + let providerSessionId = input.source === "codex" ? "" : input.fallbackSessionId; + let title: string | null = null; + let model: string | null = null; + let hasCodexSessionId = false; + const messages: Array = []; + let firstUserMessage: + | (AgentSessionThreadMessage & { readonly codexResponseUser: boolean }) + | undefined; + function* decodedRecords() { + for (const line of lines) { + const decoded = decodeTranscriptRecord(line); + if (Option.isSome(decoded)) yield decoded.value; + } + } + + // A Codex response item can include generated setup text beside the real + // prompt. Suppress response-user records only when the shared turn ID and a + // verbatim event copy prove which prompt the user submitted. + const canonicalCodexResponseUserIndices = new Set(); + let canonicalUserTextsInTurn = new Set(); + let responseUsersInTurn: Array<{ + readonly index: number; + readonly turnId: string; + readonly text: string; + }> = []; + const finishCodexTurn = () => { + const canonicalTurnIds = new Set( + responseUsersInTurn.flatMap((responseUser) => + canonicalUserTextsInTurn.has(responseUser.text) ? [responseUser.turnId] : [], + ), + ); + for (const responseUser of responseUsersInTurn) { + if (canonicalTurnIds.has(responseUser.turnId)) { + canonicalCodexResponseUserIndices.add(responseUser.index); + } + } + canonicalUserTextsInTurn = new Set(); + responseUsersInTurn = []; + }; + if (input.source === "codex") { + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "assistant" + ) { + finishCodexTurn(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message?.trim() ?? ""; + if (text.length > 0) canonicalUserTextsInTurn.add(text); + continue; + } + if ( + record.type === "response_item" && + record.payload?.type === "message" && + record.payload.role === "user" + ) { + const turnId = codexTurnId(record.payload.internal_chat_message_metadata_passthrough); + const text = extractText(record.payload.content); + if (turnId !== null && text.length > 0) { + responseUsersInTurn.push({ index: recordIndex, turnId, text }); + } + } + } + finishCodexTurn(); + } + + const retainMessage = ( + message: AgentSessionThreadMessage & { readonly codexResponseUser: boolean }, + ) => { + if (firstUserMessage === undefined && message.role === "user") { + firstUserMessage = message; + } + messages.push(message); + if (messages.length > MAX_IMPORTED_MESSAGES) messages.shift(); + }; + + const hasMatchingCodexEventInTurn = (text: string) => { + const comparisonText = text.trim(); + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") return false; + if ( + message?.role === "user" && + !message.codexResponseUser && + message.text.trim() === comparisonText + ) { + return true; + } + } + return false; + }; + + let recordIndex = -1; + for (const record of decodedRecords()) { + recordIndex += 1; + if (input.source === "claudeAgent") { + if ( + record.isSidechain === true || + record.isMeta === true || + record.isCompactSummary === true + ) { + continue; + } + if (record.sessionId?.trim()) providerSessionId = record.sessionId.trim(); + if (record.aiTitle?.trim()) title = record.aiTitle.trim(); + const messageModel = record.message?.model?.trim(); + // Claude uses this sentinel for local error responses. It is not a + // model ID that can be selected when the imported session resumes. + if (messageModel && messageModel !== "") model = messageModel; + if (record.type !== "user" && record.type !== "assistant") { + continue; + } + + const text = extractText(record.message?.content); + if (text.length === 0) continue; + retainMessage({ + role: record.type, + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + + if (record.type === "session_meta") { + const sessionId = record.payload?.id?.trim() || record.payload?.session_id?.trim(); + if (!hasCodexSessionId && sessionId) { + providerSessionId = sessionId; + hasCodexSessionId = true; + } + continue; + } + if (record.type === "turn_context" && record.payload?.model?.trim()) { + model = record.payload.model.trim(); + continue; + } + if (record.type === "event_msg" && record.payload?.type === "user_message") { + const text = record.payload.message ?? ""; + if (text.trim().length === 0) continue; + // Codex can write the same prompt as both a response item and an event. + // Remove only the matching response copy so mixed-format logs keep every + // distinct user message. + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index]; + if (message?.role === "assistant") break; + if (message?.codexResponseUser === true && message.text.trim() === text.trim()) { + if (firstUserMessage === message) firstUserMessage = undefined; + messages.splice(index, 1); + break; + } + } + retainMessage({ + role: "user", + text, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: false, + }); + continue; + } + if ( + record.type !== "response_item" || + record.payload?.type !== "message" || + (record.payload.role !== "user" && record.payload.role !== "assistant") + ) { + continue; + } + + const extractedText = extractText(record.payload.content); + if (extractedText.length === 0) continue; + if (record.payload.role === "user" && canonicalCodexResponseUserIndices.has(recordIndex)) { + continue; + } + if (record.payload.role === "user" && hasMatchingCodexEventInTurn(extractedText)) { + continue; + } + retainMessage({ + role: record.payload.role, + text: extractedText, + createdAt: normalizeTimestamp(record.timestamp, fallbackTimestamp), + codexResponseUser: record.payload.role === "user", + }); + } + + const visibleMessages = messages.map( + ({ codexResponseUser: _codexResponseUser, ...message }) => message, + ); + if (providerSessionId.trim().length === 0 || firstUserMessage === undefined) return null; + const firstUserMessageRetained = messages.includes(firstUserMessage); + const { codexResponseUser: _codexResponseUser, ...visibleFirstUserMessage } = firstUserMessage; + const retainedMessages = firstUserMessageRetained + ? visibleMessages + : [visibleFirstUserMessage, ...visibleMessages.slice(-(MAX_IMPORTED_MESSAGES - 1))]; + const derivedTitle = visibleFirstUserMessage.text.trim().split("\n")[0]?.slice(0, 100).trim(); + + return { + source: input.source, + providerInstanceId: input.providerInstanceId, + providerSessionId, + title: title ?? (derivedTitle && derivedTitle.length > 0 ? derivedTitle : "Imported thread"), + model, + createdAt: retainedMessages[0]?.createdAt ?? fallbackTimestamp, + updatedAt: fallbackTimestamp, + messages: retainedMessages, + }; +} + +/** + * T3 Code runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.t3/worktrees` layout, which + * also catches sandboxes from other T3 homes on the same machine. Separators + * are normalized (and, on Windows, case folded) so the prefix match holds + * there too. Callers check both the recorded spelling and its realpath so a + * symlink into the worktrees directory cannot bypass the filter. + */ +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +function transcriptIdentity(filePath: string, stats: FileSystem.File.Info) { + return { + filePath, + size: Number(stats.size), + mtimeMs: Option.match(stats.mtime, { onNone: () => null, onSome: (date) => date.getTime() }), + device: stats.dev, + inode: Option.getOrNull(stats.ino), + birthtimeMs: Option.match(stats.birthtime, { + onNone: () => null, + onSome: (date) => date.getTime(), + }), + }; +} + +function sameTranscriptIdentity( + left: ReturnType, + right: ReturnType, +): boolean { + return ( + left.filePath === right.filePath && + left.size === right.size && + left.mtimeMs === right.mtimeMs && + left.device === right.device && + left.inode === right.inode && + left.birthtimeMs === right.birthtimeMs + ); +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const baseDir = path.resolve(serverConfig.baseDir); + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + const excludedProjectRoots = new Set( + [NodeOS.homedir(), NodeOS.tmpdir()].map((directory) => + normalizeProjectPathForComparison(path.resolve(directory)), + ), + ); + + const isExcludedProjectPath = (candidatePath: string) => + excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || + normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( + normalizeForWorktreeMatch(baseDir, foldWorktreeCase), + ) || + isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** Match directory aliases without assuming the host volume is case-insensitive. */ + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( + target: string, + knownStats?: FileSystem.File.Info, + ) { + const resolved = path.resolve(target); + const stats = knownStats === undefined ? yield* statOption(resolved) : Option.some(knownStats); + if ( + Option.isSome(stats) && + Option.isSome(stats.value.ino) && + Number.isSafeInteger(stats.value.ino.value) && + stats.value.ino.value > 0 + ) { + return `inode:${stats.value.dev}:${stats.value.ino.value}`; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + return `path:${normalizeProjectPathForComparison(realPath)}`; + }); + + // A large history snapshot can precede session metadata. Read bounded + // chunks until a complete record names its cwd or the safety budget ends. + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* ( + transcript: TranscriptCandidate, + budget: MetadataReadBudget, + ) { + if (transcript.size === 0) return null; + if ( + budget.bytesRemaining === 0 || + budget.operationsRemaining < 2 || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return null; + } + budget.operationsRemaining -= 1; + return yield* Effect.scoped( + fileSystem.open(transcript.filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + const decoder = new TextDecoder(); + let remaining = ""; + let bytesRead = 0; + let recordsRead = 0; + const maxBytes = Math.min(MAX_TRANSCRIPT_SCAN_BYTES, transcript.size); + const reserveRecord = () => { + if ( + recordsRead === MAX_METADATA_RECORDS_PER_TRANSCRIPT || + budget.recordsRemaining === 0 + ) { + budget.truncated = true; + return false; + } + recordsRead += 1; + budget.recordsRemaining -= 1; + return true; + }; + const readLastRecord = () => { + const record = remaining + decoder.decode(); + return record.length === 0 || !reserveRecord() ? null : extractCwd(record.trim()); + }; + + while (bytesRead < maxBytes) { + if (budget.bytesRemaining === 0 || budget.operationsRemaining === 0) { + budget.truncated = true; + return null; + } + const readSize = Math.min( + METADATA_READ_BYTES, + maxBytes - bytesRead, + budget.bytesRemaining, + ); + budget.operationsRemaining -= 1; + budget.bytesRemaining -= readSize; + const next = yield* file.readAlloc(readSize); + if (Option.isNone(next)) { + return readLastRecord(); + } + + bytesRead += next.value.byteLength; + remaining += decoder.decode(next.value, { stream: true }); + const lines = remaining.split("\n"); + remaining = lines.pop() ?? ""; + + for (const line of lines) { + if (!reserveRecord()) return null; + const cwd = extractCwd(line.trim()); + if (cwd !== null) return cwd; + } + } + + if (bytesRead < transcript.size) { + budget.truncated = true; + return null; + } + return readLastRecord(); + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** Check the open file before and after reading, without reading past its reserved byte budget. */ + const readTranscript = Effect.fn("AgentSessionScanner.readTranscript")(function* ( + filePath: string, + expected: ReturnType, + ) { + if (expected.size > MAX_IMPORTED_TRANSCRIPT_BYTES) return null; + + return yield* Effect.scoped( + fileSystem.open(filePath, { flag: "r" }).pipe( + Effect.flatMap((file) => + Effect.gen(function* () { + if (!sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat))) { + return null; + } + const decoder = new TextDecoder(); + let contents = ""; + let bytesRead = 0; + + while (bytesRead < expected.size) { + const next = yield* file.readAlloc( + Math.min(TRANSCRIPT_PREFIX_BYTES, expected.size - bytesRead), + ); + if (Option.isNone(next)) { + return null; + } + + bytesRead += next.value.byteLength; + contents += decoder.decode(next.value, { stream: true }); + } + + return sameTranscriptIdentity(expected, transcriptIdentity(filePath, yield* file.stat)) + ? contents + decoder.decode() + : null; + }), + ), + ), + ).pipe(Effect.orElseSucceed(() => null)); + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string, environmentHome?: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = environmentHome?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + const discoverClaudeTranscripts = Effect.fn("AgentSessionScanner.discoverClaudeTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const projectsDir = path.join(homePath, "projects"); + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + const projectDirectories = yield* readDirectory(projectsDir); + const transcripts: Array = []; + + for (const projectDirectory of projectDirectories) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(projectsDir, projectDirectory); + const directoryTranscripts = (yield* readDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + + for (const filePath of directoryTranscripts) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isNone(stats) || + stats.value.type !== "File" || + Option.isNone(stats.value.mtime) + ) { + continue; + } + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + return { transcripts, truncated }; + }, + ); + + const discoverCodexTranscripts = Effect.fn("AgentSessionScanner.discoverCodexTranscripts")( + function* (homePath: string, providerInstanceId: ProviderInstanceId, operationBudget: number) { + const sessionsDir = path.join(homePath, "sessions"); + + const transcripts: Array = []; + let operationsRemaining = operationBudget; + let truncated = false; + const readDirectory = (directory: string) => { + if (operationsRemaining <= 0) { + truncated = true; + return Effect.succeed>([]); + } + operationsRemaining -= 1; + return listDirectory(directory); + }; + // Date-partitioned directories sort chronologically, so walking them in + // reverse spends each home's share of the operation budget on recent sessions. + for (const year of (yield* readDirectory(sessionsDir)).toSorted().toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const month of (yield* readDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + for (const day of (yield* readDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* readDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + if (operationsRemaining <= 0) { + truncated = true; + break; + } + const filePath = path.join(directory, entry); + operationsRemaining -= 1; + const stats = yield* statOption(filePath); + if ( + Option.isSome(stats) && + stats.value.type === "File" && + Option.isSome(stats.value.mtime) + ) { + transcripts.push({ + filePath, + mtimeMs: stats.value.mtime.value.getTime(), + providerInstanceId, + size: Number(stats.value.size), + }); + } + } + } + } + } + return { transcripts, truncated }; + }, + ); + + const groupTranscriptsByCwd = Effect.fn("AgentSessionScanner.groupTranscriptsByCwd")(function* ( + source: AgentSessionSource, + transcripts: ReadonlyArray, + budget: MetadataReadBudget, + ) { + const byOwnerAndCwd = new Map< + string, + { + cwd: string; + providerInstanceId: ProviderInstanceId; + lastActiveAtMs: number; + transcripts: Array<{ filePath: string; mtimeMs: number }>; + } + >(); + + for (const transcript of transcripts) { + const cwd = yield* readCwd(transcript, budget); + if (cwd === null) continue; + const key = `${transcript.providerInstanceId}\0${cwd}`; + const existing = byOwnerAndCwd.get(key); + if (existing) { + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, transcript.mtimeMs); + existing.transcripts.push(transcript); + } else { + byOwnerAndCwd.set(key, { + cwd, + providerInstanceId: transcript.providerInstanceId, + lastActiveAtMs: transcript.mtimeMs, + transcripts: [transcript], + }); + } + } + + return Array.from(byOwnerAndCwd.values(), (group): RawCandidate => ({ + cwd: group.cwd, + source, + providerInstanceId: group.providerInstanceId, + threadCount: group.transcripts.length, + lastActiveAtMs: group.lastActiveAtMs, + transcripts: group.transcripts, + })); + }); + + const collectCandidates = Effect.fn("AgentSessionScanner.collectCandidates")(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw: Array = []; + let truncated = false; + + for (const source of ["claudeAgent", "codex"] as const) { + const instances: Array<{ + readonly instanceId: ProviderInstanceId; + readonly config: ProviderInstanceConfig; + }> = Object.entries(settings.providerInstances) + .filter( + ([, instance]) => instance.driver === source && resolveProviderInstanceEnabled(instance), + ) + .map(([instanceId, config]) => ({ + instanceId: ProviderInstanceId.make(instanceId), + config, + })); + if (!Object.hasOwn(settings.providerInstances, source)) { + const legacyInstance = { + instanceId: ProviderInstanceId.make(source), + config: { + driver: ProviderDriverKind.make(source), + config: settings.providers[source], + }, + }; + if (resolveProviderInstanceEnabled(legacyInstance.config)) { + instances.push(legacyInstance); + } + } + + // A shared home contains one copy of each session. Prefer the built-in + // instance as its owner, then keep configured order for custom accounts. + instances.sort((left, right) => { + const leftDefault = left.instanceId === source ? 0 : 1; + const rightDefault = right.instanceId === source ? 0 : 1; + return leftDefault - rightDefault; + }); + const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; + const seenHomes = new Set(); + for (const { instanceId, config: instance } of instances) { + const homeVariable = source === "claudeAgent" ? "CLAUDE_CONFIG_DIR" : "CODEX_HOME"; + const environmentHome = + instance.environment?.findLast((variable) => variable.name === homeVariable)?.value ?? + hostEnvironment[homeVariable]; + + let homePath: string; + if (source === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + homePath = resolveClaudeConfigDir(config.value.homePath, environmentHome); + } else { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isNone(config)) continue; + const codexSettings = + config.value.homePath.trim().length === 0 && + config.value.shadowHomePath.trim().length === 0 && + environmentHome?.trim() + ? { ...config.value, homePath: environmentHome } + : config.value; + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + homePath = layout.sharedHomePath; + } + + const homeKey = `${source}\0${yield* directoryIdentity(homePath)}`; + if (seenHomes.has(homeKey)) continue; + seenHomes.add(homeKey); + homes.push({ homePath, providerInstanceId: instanceId }); + } + + const transcriptCandidates: Array = []; + const baseOperationBudget = Math.floor( + MAX_DISCOVERY_OPERATIONS_PER_SOURCE / Math.max(1, homes.length), + ); + const extraOperationBudgets = MAX_DISCOVERY_OPERATIONS_PER_SOURCE % Math.max(1, homes.length); + for (const [index, home] of homes.entries()) { + const operationBudget = baseOperationBudget + (index < extraOperationBudgets ? 1 : 0); + if (operationBudget === 0) { + truncated = true; + continue; + } + const discovered = yield* source === "claudeAgent" + ? discoverClaudeTranscripts(home.homePath, home.providerInstanceId, operationBudget) + : discoverCodexTranscripts(home.homePath, home.providerInstanceId, operationBudget); + truncated ||= discovered.truncated; + transcriptCandidates.push(...discovered.transcripts); + } + + transcriptCandidates.sort( + (left, right) => + right.mtimeMs - left.mtimeMs || left.filePath.localeCompare(right.filePath), + ); + if (transcriptCandidates.length > MAX_TRANSCRIPTS_PER_SOURCE) { + truncated = true; + } + // Give each account a turn before taking another file from the same home. + const selectedTranscripts = selectMetadataTranscripts(transcriptCandidates); + const metadataBudget: MetadataReadBudget = { + bytesRemaining: MAX_METADATA_BYTES_PER_SOURCE, + operationsRemaining: MAX_METADATA_OPERATIONS_PER_SOURCE, + recordsRemaining: MAX_METADATA_RECORDS_PER_SOURCE, + truncated: false, + }; + raw.push(...(yield* groupTranscriptsByCwd(source, selectedTranscripts, metadataBudget))); + truncated ||= metadataBudget.truncated; + } + + return { candidates: raw, truncated }; + }); + + let cachedCandidates: ReadonlyArray | null = null; + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const { candidates: raw, truncated } = yield* collectCandidates(); + cachedCandidates = raw; + + // Filesystem identity merges symlinks and case aliases without collapsing + // distinct case-sensitive directories. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + } + >(); + const directoryKeys = new Map(); + + for (const candidate of raw) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if (isExcludedProjectPath(resolved)) continue; + let key = directoryKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + directoryKeys.set(resolved, ""); + continue; + } + const realPath = yield* fileSystem + .realPath(resolved) + .pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isExcludedProjectPath(realPath)) { + key = ""; + } else { + key = yield* directoryIdentity(resolved, stats.value); + } + directoryKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + // Resolve persisted roots too. A project and a transcript can name + // different symlinks to the same directory. + const shellSnapshot = yield* projectionSnapshotQuery + .getShellSnapshot() + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + const importedProjectsByRoot = new Map(); + for (const project of shellSnapshot.projects) { + const projectRoot = path.resolve(expandHomePath(project.workspaceRoot)); + importedProjectsByRoot.set(normalizeProjectPathForComparison(projectRoot), project); + importedProjectsByRoot.set(yield* directoryIdentity(projectRoot), project); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Keep the path key for missing roots and use filesystem identity for + // aliases that resolve to the same directory. + const importedProject = + importedProjectsByRoot.get(normalizeProjectPathForComparison(entry.path)) ?? + importedProjectsByRoot.get(key); + const candidatePath = importedProject?.workspaceRoot ?? entry.path; + candidates.push({ + path: candidatePath, + title: path.basename(candidatePath) || candidatePath, + ...(importedProject === undefined ? {} : { projectId: importedProject.id }), + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported: importedProject !== undefined, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + ...(truncated ? { truncated: true } : {}), + }; + }); + + const prepareRecentThreads = Effect.fn("AgentSessionScanner.prepareRecentThreads")(function* ( + workspaceRoot: string, + completedSources: ReadonlyArray, + ) { + const root = path.resolve(expandHomePath(workspaceRoot)); + const realRoot = yield* fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)); + if (isExcludedProjectPath(root) || isExcludedProjectPath(realRoot)) return Stream.empty; + const rootIdentity = yield* directoryIdentity(root); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + const cutoffMs = nowMs - RECENT_THREAD_WINDOW_MS; + + const candidates = cachedCandidates ?? (yield* collectCandidates()).candidates; + cachedCandidates = candidates; + + const eligibleTranscripts: Array<{ + readonly candidate: RawCandidate; + readonly transcript: RawCandidate["transcripts"][number] & { readonly mtimeMs: number }; + }> = []; + for (const candidate of candidates) { + const expanded = expandHomePath(candidate.cwd.trim()); + if (!path.isAbsolute(expanded)) continue; + const resolved = path.resolve(expanded); + if ((yield* directoryIdentity(resolved)) !== rootIdentity) continue; + + for (const transcript of candidate.transcripts) { + if ( + transcript.mtimeMs === null || + transcript.mtimeMs < cutoffMs || + transcript.mtimeMs > nowMs + ) { + continue; + } + eligibleTranscripts.push({ + candidate, + transcript: { ...transcript, mtimeMs: transcript.mtimeMs }, + }); + } + } + + eligibleTranscripts.sort((left, right) => { + if (left.transcript.mtimeMs !== right.transcript.mtimeMs) { + return right.transcript.mtimeMs - left.transcript.mtimeMs; + } + return left.transcript.filePath.localeCompare(right.transcript.filePath); + }); + + const completedByFile = Map.groupBy( + completedSources, + (source) => `${source.providerInstanceId}\0${source.filePath}`, + ); + const importedSessions = new Set(); + let bytesRemaining = MAX_IMPORT_BYTES; + let transcriptsRemaining = MAX_IMPORT_TRANSCRIPTS; + let recordsRemaining = MAX_IMPORT_RECORDS; + return Stream.fromIteratorSucceed(eligibleTranscripts.values(), 1).pipe( + Stream.mapEffect(({ candidate, transcript }) => + Effect.gen(function* () { + const completed = completedByFile.get( + `${candidate.providerInstanceId}\0${transcript.filePath}`, + ); + if ( + completed === undefined && + (transcriptsRemaining === 0 || bytesRemaining === 0 || recordsRemaining === 0) + ) { + return Option.some({ _tag: "Skipped" }); + } + const stats = yield* statOption(transcript.filePath); + if (Option.isNone(stats) || stats.value.type !== "File") { + return Option.some({ _tag: "Skipped" }); + } + const identity = transcriptIdentity(transcript.filePath, stats.value); + const completedSource = completed?.find( + (source) => + source.provider === candidate.source && sameTranscriptIdentity(source, identity), + ); + if (completedSource !== undefined) { + const sessionKey = `${completedSource.providerInstanceId}\0${completedSource.providerSessionId}`; + if (importedSessions.has(sessionKey)) return Option.none(); + importedSessions.add(sessionKey); + return Option.some({ + _tag: "AlreadyImported", + source: completedSource, + }); + } + if ( + transcriptsRemaining === 0 || + recordsRemaining === 0 || + identity.size > MAX_IMPORTED_TRANSCRIPT_BYTES || + identity.size > bytesRemaining + ) { + return Option.some({ _tag: "Skipped" }); + } + // Reserve the whole file even if its read or parse fails. + transcriptsRemaining -= 1; + bytesRemaining -= identity.size; + const contents = yield* readTranscript(transcript.filePath, identity); + if (contents === null) { + return Option.some({ _tag: "Skipped" }); + } + const lines = splitTranscriptRecords(contents, recordsRemaining + 1); + if (lines.length > recordsRemaining) { + return Option.some({ _tag: "Skipped" }); + } + recordsRemaining -= lines.length; + + // A stable replacement file can belong to a different project than the cached candidate. + let snapshotCwd: string | null = null; + for (const line of lines) { + snapshotCwd = extractCwd(line); + if (snapshotCwd !== null) break; + } + if (snapshotCwd === null) { + return Option.some({ _tag: "Skipped" }); + } + const expandedCwd = expandHomePath(snapshotCwd.trim()); + if ( + !path.isAbsolute(expandedCwd) || + (yield* directoryIdentity(path.resolve(expandedCwd))) !== rootIdentity + ) { + return Option.some({ _tag: "Skipped" }); + } + + const parsedThread = parseAgentSessionTranscript( + { + contents, + source: candidate.source, + providerInstanceId: candidate.providerInstanceId, + fallbackSessionId: path.basename(transcript.filePath, ".jsonl"), + lastActiveAtMs: transcript.mtimeMs, + }, + lines, + ); + if (parsedThread === null) { + return Option.some({ _tag: "Skipped" }); + } + + const source: AgentSessionImportSource = { + ...identity, + provider: parsedThread.source, + providerInstanceId: parsedThread.providerInstanceId, + providerSessionId: parsedThread.providerSessionId, + }; + const sessionKey = `${parsedThread.providerInstanceId}\0${parsedThread.providerSessionId}`; + if (importedSessions.has(sessionKey)) { + return Option.some({ _tag: "Duplicate", source }); + } + importedSessions.add(sessionKey); + return Option.some({ + _tag: "Importable", + thread: parsedThread, + source, + }); + }), + ), + Stream.map(Option.toArray), + Stream.flattenIterable, + ); + }); + + const recentThreads: AgentSessionScanner["Service"]["recentThreads"] = ( + workspaceRoot, + completedSources = [], + ) => Stream.unwrap(prepareRecentThreads(workspaceRoot, completedSources)); + + return AgentSessionScanner.of({ scan, recentThreads }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 46fee8d8add6..6cc399dd4de2 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -41,6 +41,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getProjectShellById: (projectId) => Effect.succeed(projectId === project.id ? Option.some(project) : Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 56cb1cb06f08..324284a3a4c7 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -26,6 +26,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeClaudeTextGeneration } from "../../textGeneration/ClaudeTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeClaudeAdapter } from "../Layers/ClaudeAdapter.ts"; @@ -119,7 +120,11 @@ export const ClaudeDriver: ProviderDriver = { driverKind: DRIVER_KIND, instanceId, }); - const effectiveConfig = { ...config, enabled } satisfies ClaudeSettings; + const effectiveConfig = { + ...config, + enabled, + binaryPath: expandHomePath(config.binaryPath), + } satisfies ClaudeSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( resolveProviderMaintenanceCapabilitiesEffect(UPDATE, { binaryPath: effectiveConfig.binaryPath, diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index d7fd1e9c5698..071fb20674a8 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -33,6 +33,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { makeCodexTextGeneration } from "../../textGeneration/CodexTextGeneration.ts"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; @@ -157,6 +158,7 @@ export const CodexDriver: ProviderDriver = { const effectiveConfig = { ...config, enabled, + binaryPath: expandHomePath(config.binaryPath), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; const resolveMaintenance = yield* makeCachedProviderMaintenanceResolution( diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 2ca44a2a5f0a..4676d780a530 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -222,6 +222,7 @@ function makeScopedRuntimeFactory(options?: { readonly failConstruction?: boolea const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 81e799c9095e..082e20d7cb54 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -516,6 +516,7 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { const providerSessionDirectoryTestLayer = Layer.succeed(ProviderSessionDirectory, { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die(new Error("ProviderSessionDirectory.getProvider is not used in test")), getBinding: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts index af43e039e652..25dafa5ba040 100644 --- a/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts +++ b/apps/server/src/provider/Layers/ProviderInstanceRegistryLive.test.ts @@ -24,7 +24,6 @@ */ import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import * as Path from "effect/Path"; import { type ClaudeSettings, type CodexSettings, @@ -35,9 +34,12 @@ import { type ProviderInstanceConfigMap, ProviderInstanceId, } from "@t3tools/contracts"; +import { isHostWindows } from "@t3tools/shared/hostProcess"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import { HttpClient, HttpClientResponse } from "effect/unstable/http"; @@ -45,6 +47,7 @@ import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import type { BuiltInDriversEnv } from "../builtInDrivers.ts"; import { AntigravityInstallation } from "../AntigravityInstallation.ts"; import { ServerConfig } from "../../config.ts"; +import { expandHomePath } from "../../pathExpansion.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ClaudeDriver } from "../Drivers/ClaudeDriver.ts"; import { CodexDriver } from "../Drivers/CodexDriver.ts"; @@ -139,6 +142,80 @@ const makeOpenCodeConfig = (overrides: Partial): OpenCodeSetti ...overrides, }); +const makeTildeProviderFixtures = Effect.fn( + "ProviderInstanceRegistryLive.test.makeTildeProviderFixtures", +)(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const homePath = expandHomePath("~"); + const fixtureDir = yield* fileSystem.makeTempDirectoryScoped({ + directory: homePath, + prefix: ".t3-provider-path-test-", + }); + const codexPath = path.join(fixtureDir, "codex"); + const claudePath = path.join(fixtureDir, "claude"); + const claudeHomePath = path.join(fixtureDir, "claude-home"); + const codexScriptPath = path.join(fixtureDir, "codex-script.json"); + const codexFixtureDir = path.join(import.meta.dirname, "../testFixtures"); + + yield* fileSystem.copyFile(path.join(codexFixtureDir, "codexCollabMockPeer.sh"), codexPath); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexCollabMockPeer.mjs"), + path.join(fixtureDir, "codexCollabMockPeer.mjs"), + ); + yield* fileSystem.copyFile( + path.join(codexFixtureDir, "codexMultiAgentWire.json"), + path.join(fixtureDir, "codexMultiAgentWire.json"), + ); + yield* fileSystem.writeFileString( + codexScriptPath, + // @effect-diagnostics-next-line preferSchemaOverJson:off - fixed script document read by the external Codex mock peer. + JSON.stringify({ rootThreadId: "probe-thread", notifications: [] }), + ); + yield* fileSystem.chmod(codexPath, 0o755); + + yield* fileSystem.writeFileString( + claudePath, + [ + "#!/usr/bin/env node", + 'import * as NodeReadline from "node:readline";', + 'if (process.argv.includes("--version")) {', + ' process.stdout.write("claude 2.1.219\\n");', + " process.exit(0);", + "}", + "const lines = NodeReadline.createInterface({ input: process.stdin });", + 'lines.on("line", (line) => {', + " const message = JSON.parse(line);", + ' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;', + " process.stdout.write(JSON.stringify({", + ' type: "control_response",', + " response: {", + ' subtype: "success",', + " request_id: message.request_id,", + " response: {", + " commands: [], agents: [], models: [],", + ' output_style: "default", available_output_styles: ["default"],', + ' account: { email: "test@example.com", subscriptionType: "pro", tokenSource: "oauth" },', + " },", + " },", + ' }) + "\\n");', + "});", + "setInterval(() => {}, 1_000);", + "", + ].join("\n"), + ); + yield* fileSystem.chmod(claudePath, 0o755); + yield* fileSystem.makeDirectory(claudeHomePath); + + const asTildePath = (filePath: string) => `~/${path.relative(homePath, filePath)}`; + return { + codexBinaryPath: asTildePath(codexPath), + claudeBinaryPath: asTildePath(claudePath), + claudeHomePath, + codexScriptPath, + }; +}); + describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { // `ServerConfig.layerTest` needs `FileSystem` to materialize its scratch // directory. `Layer.merge` just unions requirements, so we have to push @@ -261,6 +338,60 @@ describe("ProviderInstanceRegistryLive — multi-instance codex slice", () => { }).pipe(Effect.provide(testLayer)), ); + it.live("runs Codex and Claude readiness probes from configured tilde paths", () => + Effect.gen(function* () { + if (yield* isHostWindows) return; + + const fixtures = yield* makeTildeProviderFixtures(); + + const codexId = ProviderInstanceId.make("codex_tilde"); + const claudeId = ProviderInstanceId.make("claude_tilde"); + const configMap: ProviderInstanceConfigMap = { + [codexId]: { + driver: ProviderDriverKind.make("codex"), + enabled: true, + environment: [ + { + name: "T3_CODEX_COLLAB_SCRIPT", + value: fixtures.codexScriptPath, + sensitive: false, + }, + ], + config: makeCodexConfig({ enabled: true, binaryPath: fixtures.codexBinaryPath }), + }, + [claudeId]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: true, + config: makeClaudeConfig({ + enabled: true, + binaryPath: fixtures.claudeBinaryPath, + homePath: fixtures.claudeHomePath, + }), + }, + }; + + const { registry } = yield* makeProviderInstanceRegistry({ + drivers: [CodexDriver, ClaudeDriver], + configMap, + }); + const codex = yield* registry.getInstance(codexId); + const claude = yield* registry.getInstance(claudeId); + expect(codex).toBeDefined(); + expect(claude).toBeDefined(); + + const [codexSnapshot, claudeSnapshot] = yield* Effect.all( + [codex!.snapshot.refresh, claude!.snapshot.refresh], + { concurrency: "unbounded" }, + ); + expect(codexSnapshot).toMatchObject({ status: "ready", installed: true, version: "0.0.0" }); + expect(claudeSnapshot).toMatchObject({ + status: "ready", + installed: true, + version: "2.1.219", + }); + }).pipe(Effect.provide(testLayer)), + ); + it.live( "shadows instances whose driver is not registered in this build without failing boot", () => diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index f5b9be91650f..238265ec5a45 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4271,6 +4271,7 @@ const getBinding = vi.fn((threadId: ThreadId) => const boundedListing = makeProviderServiceLayer({ directory: { upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("ProviderService.listSessions does not use getProvider"), getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts index 079b7f10ebfd..8b41bd3e518c 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts @@ -4,9 +4,13 @@ import * as NodeOS from "node:os"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; -import { ProviderDriverKind, ThreadId } from "@t3tools/contracts"; -import { it, assert } from "@effect/vitest"; -import { assertSome } from "@effect/vitest/utils"; +import { + ProviderDriverKind, + ProviderInstanceId, + ThreadId, + type AgentSessionImportSource, +} from "@t3tools/contracts"; +import { assert, expect, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; @@ -20,9 +24,22 @@ import * as ProviderSessionRuntime from "../../persistence/ProviderSessionRuntim import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; +const importedSource = { + provider: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + providerSessionId: "provider-session", + filePath: "/tmp/provider-session.jsonl", + size: 100, + mtimeMs: 1_000, + device: 1, + inode: 123, + birthtimeMs: 500, +} satisfies AgentSessionImportSource; + function makeDirectoryLayer(persistenceLayer: Layer.Layer) { const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(Layer.provide(persistenceLayer)); return Layer.mergeAll( + persistenceLayer, runtimeRepositoryLayer, ProviderSessionDirectoryLive.pipe(Layer.provide(runtimeRepositoryLayer)), NodeServices.layer, @@ -30,7 +47,7 @@ function makeDirectoryLayer(persistenceLayer: Layer.Layer { - it("upserts and reads thread bindings", () => + it.effect("upserts and reads thread bindings", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -39,13 +56,14 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: initialThreadId, }); const provider = yield* directory.getProvider(initialThreadId); assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(initialThreadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId: initialThreadId, provider: ProviderDriverKind.make("codex"), }); @@ -57,6 +75,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId: nextThreadId, }); const updatedBinding = yield* directory.getBinding(nextThreadId); @@ -74,10 +93,11 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL } const threadIds = yield* directory.listThreadIds(); - assert.deepEqual(threadIds, [nextThreadId]); - })); + expect(threadIds).toEqual(expect.arrayContaining([initialThreadId, nextThreadId])); + }), + ); - it("persists runtime fields and merges payload updates", () => + it.effect("persists runtime fields and merges payload updates", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -86,6 +106,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "starting", resumeCursor: { @@ -99,6 +120,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, status: "running", runtimePayload: { @@ -120,9 +142,158 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL activeTurnId: "turn-1", }); } - })); + }), + ); + + it.effect("keeps the existing binding when an insert conflicts", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("thread-insert-conflict"); + + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + + yield* directory.upsert( + { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + status: "stopped", + resumeCursor: { threadId: "stale-provider-thread" }, + }, + { onConflict: "ignore" }, + ); + + const binding = yield* directory.getBinding(threadId); + expect(Option.getOrThrow(binding)).toMatchObject({ + threadId, + status: "running", + resumeCursor: { threadId: "active-provider-thread" }, + }); + }), + ); + + it.effect("records source files without replacing the current provider session", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const source = { ...importedSource, providerSessionId: "record-source" }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const runtimePayload = { cwd: "/tmp/project", activeTurnId: "active-turn" }; + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claude-current"), + status: "running", + resumeCursor: { resume: "current-native-session" }, + runtimePayload, + }); + const before = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + + yield* directory.recordImportedTranscript({ threadId, source }); + const replacement = { ...source, size: 200, mtimeMs: 2_000 }; + yield* directory.recordImportedTranscript({ threadId, source: replacement }); + const secondFile = { ...source, filePath: "/tmp/provider-session-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondFile }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...before, + runtimePayload: { ...runtimePayload, importedTranscripts: [replacement, secondFile] }, + }); + }), + ); + + it.effect("does not create a binding when recording an imported transcript", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const threadId = ThreadId.make("import:codex:missing-source-binding"); + + yield* directory.recordImportedTranscript({ threadId, source: importedSource }); + + expect(Option.isNone(yield* directory.getBinding(threadId))).toBe(true); + }), + ); + + it.effect("keeps newly recorded sources when a runtime write uses a stale payload", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const repository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const firstSource = { ...importedSource, providerSessionId: "stale-source" }; + const threadId = ThreadId.make( + `import:${firstSource.providerInstanceId}:${firstSource.providerSessionId}`, + ); + yield* directory.upsert({ + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + status: "stopped", + resumeCursor: { threadId: "original-native-session" }, + runtimePayload: { cwd: "/tmp/stale-source-project" }, + }); + yield* directory.recordImportedTranscript({ threadId, source: firstSource }); + const stale = Option.getOrThrow(yield* repository.getByThreadId({ threadId })); + const secondSource = { ...firstSource, filePath: "/tmp/stale-source-copy.jsonl" }; + yield* directory.recordImportedTranscript({ threadId, source: secondSource }); + + yield* repository.upsert({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + }); + + expect(Option.getOrThrow(yield* repository.getByThreadId({ threadId }))).toEqual({ + ...stale, + status: "running", + resumeCursor: { threadId: "new-native-session" }, + lastSeenAt: "2026-08-24T10:00:00.000Z", + runtimePayload: { + cwd: "/tmp/stale-source-project", + importedTranscripts: [firstSource, secondSource], + }, + }); + }), + ); + + it.effect("reserves imported source records for the atomic recording method", () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + for (const onConflict of ["update", "ignore"] as const) { + const source = { ...importedSource, providerSessionId: `reserved-source-${onConflict}` }; + const threadId = ThreadId.make( + `import:${source.providerInstanceId}:${source.providerSessionId}`, + ); + const binding = { + threadId, + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + }; + yield* directory.upsert( + { ...binding, runtimePayload: { cwd: "/tmp/project", importedTranscripts: [source] } }, + { onConflict }, + ); + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + cwd: "/tmp/project", + }); + + yield* directory.recordImportedTranscript({ threadId, source }); + yield* directory.upsert({ ...binding, runtimePayload: null }); + + expect(Option.getOrThrow(yield* directory.getBinding(threadId)).runtimePayload).toEqual({ + importedTranscripts: [source], + }); + } + }), + ); - it("lists persisted bindings with metadata in oldest-first order", () => + it.effect("lists persisted bindings with metadata in oldest-first order", () => Effect.gen(function* () { const directory = yield* ProviderSessionDirectory; const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; @@ -162,12 +333,15 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }); - const bindings = yield* directory.listBindings(); + const bindings = (yield* directory.listBindings()).filter( + (binding) => binding.threadId === olderThreadId || binding.threadId === newerThreadId, + ); assert.deepEqual(bindings, [ { threadId: olderThreadId, provider: ProviderDriverKind.make("claudeAgent"), + providerInstanceId: ProviderInstanceId.make("claudeAgent"), adapterKey: "claudeAgent", runtimeMode: "approval-required", status: "starting", @@ -182,6 +356,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL { threadId: newerThreadId, provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), adapterKey: "codex", runtimeMode: "full-access", status: "running", @@ -194,40 +369,45 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }, }, ]); - })); + }), + ); - it("resets adapterKey to the new provider when provider changes without an explicit adapter key", () => - Effect.gen(function* () { - const directory = yield* ProviderSessionDirectory; - const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; - const threadId = ThreadId.make("thread-provider-change"); + it.effect( + "resets adapterKey to the new provider when provider changes without an explicit adapter key", + () => + Effect.gen(function* () { + const directory = yield* ProviderSessionDirectory; + const runtimeRepository = yield* ProviderSessionRuntime.ProviderSessionRuntimeRepository; + const threadId = ThreadId.make("thread-provider-change"); - yield* runtimeRepository.upsert({ - threadId, - providerName: "claudeAgent", - providerInstanceId: null, - adapterKey: "claudeAgent", - runtimeMode: "full-access", - status: "running", - lastSeenAt: "2026-01-01T00:00:00.000Z", - resumeCursor: null, - runtimePayload: null, - }); + yield* runtimeRepository.upsert({ + threadId, + providerName: "claudeAgent", + providerInstanceId: null, + adapterKey: "claudeAgent", + runtimeMode: "full-access", + status: "running", + lastSeenAt: "2026-01-01T00:00:00.000Z", + resumeCursor: null, + runtimePayload: null, + }); - yield* directory.upsert({ - provider: ProviderDriverKind.make("codex"), - threadId, - }); + yield* directory.upsert({ + provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), + threadId, + }); - const runtime = yield* runtimeRepository.getByThreadId({ threadId }); - assert.equal(Option.isSome(runtime), true); - if (Option.isSome(runtime)) { - assert.equal(runtime.value.providerName, "codex"); - assert.equal(runtime.value.adapterKey, "codex"); - } - })); + const runtime = yield* runtimeRepository.getByThreadId({ threadId }); + assert.equal(Option.isSome(runtime), true); + if (Option.isSome(runtime)) { + assert.equal(runtime.value.providerName, "codex"); + assert.equal(runtime.value.adapterKey, "codex"); + } + }), + ); - it("rehydrates persisted mappings across layer restart", () => + it.effect("rehydrates persisted mappings across layer restart", () => Effect.gen(function* () { const tempDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-provider-directory-")); const dbPath = NodePath.join(tempDir, "orchestration.sqlite"); @@ -239,6 +419,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL const directory = yield* ProviderSessionDirectory; yield* directory.upsert({ provider: ProviderDriverKind.make("codex"), + providerInstanceId: ProviderInstanceId.make("codex"), threadId, }); }).pipe(Effect.provide(directoryLayer)); @@ -250,7 +431,7 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL assert.equal(provider, "codex"); const resolvedBinding = yield* directory.getBinding(threadId); - assertSome(resolvedBinding, { + expect(Option.getOrThrow(resolvedBinding)).toMatchObject({ threadId, provider: ProviderDriverKind.make("codex"), }); @@ -267,5 +448,6 @@ it.layer(makeDirectoryLayer(SqlitePersistenceMemory))("ProviderSessionDirectoryL }).pipe(Effect.provide(directoryLayer)); NodeFS.rmSync(tempDir, { recursive: true, force: true }); - })); + }), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts index 253a954d2102..29ec8d2ed168 100644 --- a/apps/server/src/provider/Layers/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Layers/ProviderSessionDirectory.ts @@ -100,7 +100,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); - const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding) { + const upsert: ProviderSessionDirectoryShape["upsert"] = Effect.fn(function* (binding, options) { const existing = yield* repository .getByThreadId({ threadId: binding.threadId }) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:getByThreadId"))); @@ -126,25 +126,30 @@ const makeProviderSessionDirectory = Effect.gen(function* () { }); } yield* repository - .upsert({ - threadId: resolvedThreadId, - providerName: binding.provider, - providerInstanceId, - adapterKey: - binding.adapterKey ?? - (providerChanged ? binding.provider : (existingRuntime?.adapterKey ?? binding.provider)), - runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", - status: binding.status ?? existingRuntime?.status ?? "running", - lastSeenAt: now, - resumeCursor: - binding.resumeCursor !== undefined - ? binding.resumeCursor - : (existingRuntime?.resumeCursor ?? null), - runtimePayload: mergeRuntimePayload( - existingRuntime?.runtimePayload ?? null, - binding.runtimePayload, - ), - }) + .upsert( + { + threadId: resolvedThreadId, + providerName: binding.provider, + providerInstanceId, + adapterKey: + binding.adapterKey ?? + (providerChanged + ? binding.provider + : (existingRuntime?.adapterKey ?? binding.provider)), + runtimeMode: binding.runtimeMode ?? existingRuntime?.runtimeMode ?? "full-access", + status: binding.status ?? existingRuntime?.status ?? "running", + lastSeenAt: now, + resumeCursor: + binding.resumeCursor !== undefined + ? binding.resumeCursor + : (existingRuntime?.resumeCursor ?? null), + runtimePayload: mergeRuntimePayload( + existingRuntime?.runtimePayload ?? null, + binding.runtimePayload, + ), + }, + options, + ) .pipe(Effect.mapError(toPersistenceError("ProviderSessionDirectory.upsert:upsert"))); }); @@ -164,6 +169,15 @@ const makeProviderSessionDirectory = Effect.gen(function* () { ), ); + const recordImportedTranscript: ProviderSessionDirectoryShape["recordImportedTranscript"] = ( + input, + ) => + repository + .recordImportedTranscript(input) + .pipe( + Effect.mapError(toPersistenceError("ProviderSessionDirectory.recordImportedTranscript")), + ); + const listThreadIds: ProviderSessionDirectoryShape["listThreadIds"] = () => repository.list().pipe( Effect.mapError(toPersistenceError("ProviderSessionDirectory.listThreadIds:list")), @@ -184,6 +198,7 @@ const makeProviderSessionDirectory = Effect.gen(function* () { return { upsert, + recordImportedTranscript, getProvider, getBinding, listThreadIds, diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 1777544d8fb0..c680a8228e3b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -217,6 +217,7 @@ describe("ProviderSessionReaper", () => { getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts index 7ac3f2f2837d..7d6bbe61a2aa 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.test.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.test.ts @@ -1,8 +1,55 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeOS from "node:os"; + +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Path from "effect/Path"; import { mergeProviderInstanceEnvironment } from "./ProviderInstanceEnvironment.ts"; describe("mergeProviderInstanceEnvironment", () => { + it.effect.each([ + { value: "~/.account", tail: ".account" }, + { value: "~\\.account\\work", tail: ".account\\work" }, + ])("expands configured provider homes set to $value", ({ value, tail }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const baseEnv = { + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }; + const environment = mergeProviderInstanceEnvironment( + [ + { name: "CODEX_HOME", value, sensitive: false }, + { name: "CLAUDE_CONFIG_DIR", value, sensitive: false }, + { name: "CUSTOM_VALUE", value, sensitive: false }, + ], + baseEnv, + ); + + expect(environment).toEqual({ + CODEX_HOME: path.join(NodeOS.homedir(), tail), + CLAUDE_CONFIG_DIR: path.join(NodeOS.homedir(), tail), + CUSTOM_VALUE: value, + }); + expect(baseEnv).toEqual({ + CODEX_HOME: "~/.inherited-codex", + CLAUDE_CONFIG_DIR: "~/.inherited-claude", + }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it("leaves inherited provider homes unchanged", () => { + const baseEnv = { CODEX_HOME: "~/.codex", CLAUDE_CONFIG_DIR: "~\\.claude" }; + + expect( + mergeProviderInstanceEnvironment( + [{ name: "CUSTOM_VALUE", value: "~/.custom", sensitive: false }], + baseEnv, + ), + ).toEqual({ ...baseEnv, CUSTOM_VALUE: "~/.custom" }); + }); + it("overrides inherited environment values and preserves empty strings", () => { expect( mergeProviderInstanceEnvironment( diff --git a/apps/server/src/provider/ProviderInstanceEnvironment.ts b/apps/server/src/provider/ProviderInstanceEnvironment.ts index e469253604e6..77c0c6c2dc88 100644 --- a/apps/server/src/provider/ProviderInstanceEnvironment.ts +++ b/apps/server/src/provider/ProviderInstanceEnvironment.ts @@ -1,5 +1,7 @@ import type { ProviderInstanceEnvironment } from "@t3tools/contracts"; +import { expandHomePath } from "../pathExpansion.ts"; + export function mergeProviderInstanceEnvironment( environment: ProviderInstanceEnvironment | undefined, baseEnv: NodeJS.ProcessEnv = process.env, @@ -10,7 +12,11 @@ export function mergeProviderInstanceEnvironment( const next: NodeJS.ProcessEnv = { ...baseEnv }; for (const variable of environment) { - next[variable.name] = variable.value; + // Child processes do not apply shell expansion to environment values. + next[variable.name] = + variable.name === "CODEX_HOME" || variable.name === "CLAUDE_CONFIG_DIR" + ? expandHomePath(variable.value) + : variable.value; } return next; } diff --git a/apps/server/src/provider/Services/ProviderSessionDirectory.ts b/apps/server/src/provider/Services/ProviderSessionDirectory.ts index f2dd4323f7a3..9dbafd3e804e 100644 --- a/apps/server/src/provider/Services/ProviderSessionDirectory.ts +++ b/apps/server/src/provider/Services/ProviderSessionDirectory.ts @@ -1,4 +1,5 @@ import type { + AgentSessionImportSource, ProviderInstanceId, ProviderDriverKind, ProviderSessionRuntimeStatus, @@ -40,11 +41,22 @@ export type ProviderSessionDirectoryWriteError = | ProviderValidationError | ProviderSessionDirectoryPersistenceError; +export interface ProviderSessionDirectoryUpsertOptions { + readonly onConflict?: "update" | "ignore"; +} + export interface ProviderSessionDirectoryShape { readonly upsert: ( binding: ProviderRuntimeBinding, + options?: ProviderSessionDirectoryUpsertOptions, ) => Effect.Effect; + /** Record an imported file without changing the current provider session. */ + readonly recordImportedTranscript: (input: { + readonly threadId: ThreadId; + readonly source: AgentSessionImportSource; + }) => Effect.Effect; + readonly getProvider: ( threadId: ThreadId, ) => Effect.Effect; diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index 4e6d1b261947..fa567d75cf8a 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -57,6 +57,14 @@ rl.on("line", (line) => { }); return; } + if (method === "account/read") { + write({ id, result: { account: { type: "apiKey" }, requiresOpenaiAuth: false } }); + return; + } + if (method === "skills/list" || method === "model/list") { + write({ id, result: { data: [] } }); + return; + } if (method === "thread/start") { write({ id, result: fixture.responses.threadStart }); return; diff --git a/apps/server/src/relay/AgentAwarenessRelay.test.ts b/apps/server/src/relay/AgentAwarenessRelay.test.ts index ee23cbffaf0d..59049500729d 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.test.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.test.ts @@ -138,7 +138,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { expect(AgentAwarenessRelay.eventThreadId(event)).toBe(threadId); }); - it("does not publish start intents, streaming content, or non-awareness activity events", () => { + it("does not publish imported, start-intent, streaming, or non-awareness events", () => { const now = "2026-05-25T00:00:00.000Z"; const base = { sequence: 1, @@ -147,6 +147,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: "thread-1" as ThreadId, occurredAt: now, + metadata: {}, }; expect( @@ -202,6 +203,36 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }, } as unknown as OrchestrationEvent), ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + metadata: { historyImport: true }, + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(false); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.created", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); + expect( + AgentAwarenessRelay.shouldPublishAgentAwarenessEvent({ + ...base, + type: "thread.settled", + payload: { threadId: "thread-1" as ThreadId }, + } as unknown as OrchestrationEvent), + ).toBe(true); }); it("deduplicates awareness state updates whose only change is their event timestamp", () => { @@ -400,17 +431,32 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { }), ); - it.effect("keeps the orchestration listener armed until relay config is installed", () => + it.effect("keeps the listener armed and skips imported thread work", () => Effect.scoped( Effect.gen(function* () { const events = yield* Queue.unbounded(); const threadShellRequested = yield* Deferred.make(); + const releaseThreadShell = yield* Deferred.make(); + const threadShellRequests: Array = []; + let fetchCallCount = 0; const secrets = makeMemorySecretStore(); const now = "2026-05-25T00:00:00.000Z"; const projectId = "project-1" as ProjectId; const threadId = "thread-1" as ThreadId; + const importedThreadId = "import:codex:session-1" as ThreadId; const environmentId = "env-1" as EnvironmentId; + const originalFetch = globalThis.fetch; + globalThis.fetch = (() => { + fetchCallCount += 1; + return Promise.resolve(Response.json({ ok: true, deliveries: [] })); + }) as unknown as typeof fetch; + yield* Effect.addFinalizer(() => + Effect.sync(() => { + globalThis.fetch = originalFetch; + }), + ); + const project = { id: projectId, title: "T3 Code", @@ -473,15 +519,18 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { getShellSnapshot: () => Effect.succeed({ snapshotSequence: 1, - projects: [project], - threads: [thread], + projects: [], + threads: [], updatedAt: now, } satisfies OrchestrationShellSnapshot), - getThreadShellById: () => - Deferred.succeed(threadShellRequested, undefined).pipe( - Effect.ignore, - Effect.as(Option.some(thread)), - ), + getThreadShellById: (requestedThreadId: ThreadId) => + Effect.gen(function* () { + threadShellRequests.push(requestedThreadId); + if (requestedThreadId !== threadId) return Option.none(); + yield* Deferred.succeed(threadShellRequested, undefined); + yield* Deferred.await(releaseThreadShell); + return Option.some(thread); + }), getProjectShellById: () => Effect.succeed(Option.some(project)), } as unknown as ProjectionSnapshotQueryShape; @@ -511,17 +560,40 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { yield* Effect.gen(function* () { const relay = yield* AgentAwarenessRelay.AgentAwarenessRelay; yield* relay.start(); - yield* secrets.setString(RELAY_URL_SECRET, "http://127.0.0.1:1"); + yield* secrets.setString(RELAY_URL_SECRET, "https://relay.example.test"); yield* secrets.setString(RELAY_ENVIRONMENT_CREDENTIAL_SECRET, "relay-credential"); yield* secrets.setString(PUBLISH_AGENT_ACTIVITY_SECRET, "true"); yield* Queue.offer(events, { - type: "thread.activity-appended", + type: "thread.created", sequence: 1, + eventId: "evt-import-created", + commandId: CommandId.make("cmd-import-created"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.settled", + sequence: 2, + eventId: "evt-import-settled", + commandId: CommandId.make("cmd-import-settled"), + aggregateKind: "thread", + aggregateId: importedThreadId, + metadata: { historyImport: true }, + payload: { threadId: importedThreadId }, + occurredAt: now, + } as unknown as OrchestrationEvent); + yield* Queue.offer(events, { + type: "thread.activity-appended", + sequence: 3, eventId: "evt-1", commandId: CommandId.make("cmd-1"), aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { @@ -532,6 +604,9 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { } as unknown as OrchestrationEvent); yield* Deferred.await(threadShellRequested).pipe(Effect.timeout("2 seconds")); + expect(threadShellRequests).toEqual([threadId]); + expect(fetchCallCount).toBe(0); + yield* Deferred.succeed(releaseThreadShell, undefined); }).pipe( Effect.provide( AgentAwarenessRelay.layer.pipe( @@ -692,6 +767,7 @@ describe.sequential("signRelayAgentActivityPublishProof", () => { aggregateKind: "thread", aggregateId: threadId, actor: { kind: "server" }, + metadata: {}, payload: { threadId, activity: { diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 3dd0df642ce8..8d7b9e98361e 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -67,6 +67,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { } export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { + if (event.metadata.historyImport === true) { + return false; + } switch (event.type) { case "thread.message-sent": case "thread.turn-start-requested": diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index bebde7eded76..cfebfcf157c3 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -36,6 +36,7 @@ import { type ProviderInstallState, ProviderSetupError, ResolvedKeybindingRule, + type ServerLifecycleStreamEvent, ThreadId, TurnId, WS_METHODS, @@ -91,6 +92,7 @@ const decodeTransferThreadSnapshot = Schema.decodeUnknownEffect( const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(OrchestrationShellSnapshot), ); +const encodeTestJson = Schema.encodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; @@ -127,6 +129,7 @@ import { AntigravityInstallationError, } from "./provider/AntigravityInstallation.ts"; import type { ProviderInstance } from "./provider/ProviderDriver.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import { ProviderAdapterRequestError } from "./provider/Errors.ts"; import { makeManualOnlyProviderMaintenanceCapabilities } from "./provider/providerMaintenance.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; @@ -513,6 +516,9 @@ const buildAppUnderTest = (options?: { projectSetupScriptRunner?: Partial< ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; + providerSessionDirectory?: Partial< + ProviderSessionDirectory.ProviderSessionDirectory["Service"] + >; terminalManager?: Partial; orchestrationEngine?: Partial; threadDeletionReactor?: Partial; @@ -785,6 +791,13 @@ const buildAppUnderTest = (options?: { managedDirectory: "unused-test-antigravity-runtime", ...options?.layers?.antigravityInstallation, }), + Layer.mock(ProviderSessionDirectory.ProviderSessionDirectory)({ + upsert: () => Effect.void, + getBinding: () => Effect.succeed(Option.none()), + listThreadIds: () => Effect.succeed([]), + listBindings: () => Effect.succeed([]), + ...options?.layers?.providerSessionDirectory, + }), ), ), Layer.provide( @@ -973,6 +986,7 @@ const buildAppUnderTest = (options?: { }), getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.succeed([]), getThreadCheckpointContext: () => Effect.succeed(Option.none()), ...options?.layers?.projectionSnapshotQuery, }), @@ -5332,6 +5346,103 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("keeps agent session import project failures structured over websocket rpc", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + + const projectId = ProjectId.make("missing-import-project"); + const wsUrl = yield* getWsServerUrl("/ws"); + const error = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.agentSessionsImport]({ projectId }).pipe(Effect.flip), + ), + ); + + assert.equal(error._tag, "AgentSessionImportProjectNotFoundError"); + if (error._tag === "AgentSessionImportProjectNotFoundError") { + assert.equal(error.projectId, projectId); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("returns scanner skip counts over websocket rpc", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const codexHome = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-codex-", + }); + const workspaceRoot = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-agent-import-rpc-workspace-", + }); + const transcriptDirectory = path.join(codexHome, "sessions", "2026", "08", "31"); + const transcriptPath = path.join(transcriptDirectory, "rollout-skipped.jsonl"); + yield* fileSystem.makeDirectory(transcriptDirectory, { recursive: true }); + yield* fileSystem.writeFileString( + transcriptPath, + encodeTestJson({ + timestamp: "2026-08-31T12:00:00.000Z", + type: "session_meta", + payload: { id: "rpc-skipped-session", cwd: workspaceRoot }, + }), + ); + yield* fileSystem.utimes(transcriptPath, 0, 0); + + const projectId = ProjectId.make("agent-import-rpc-project"); + const project = { + id: projectId, + title: "Agent import RPC", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-08-31T12:00:00.000Z", + updatedAt: "2026-08-31T12:00:00.000Z", + } as const; + yield* buildAppUnderTest({ + layers: { + serverSettings: { + getSettings: Effect.succeed({ + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath: codexHome }, + }, + [ProviderInstanceId.make("claudeAgent")]: { + driver: ProviderDriverKind.make("claudeAgent"), + enabled: false, + config: {}, + }, + }, + }), + }, + projectionSnapshotQuery: { + getProjectShellById: (requestedProjectId) => + Effect.succeed( + requestedProjectId === projectId ? Option.some(project) : Option.none(), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const result = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const scan = yield* client[WS_METHODS.agentSessionsScan]({}); + assert.deepEqual( + scan.candidates.map((candidate) => candidate.path), + [workspaceRoot], + ); + return yield* client[WS_METHODS.agentSessionsImport]({ projectId }); + }), + ), + ); + + assert.deepEqual(result, { importedCount: 0, skippedCount: 1 }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("uploads Codex thread feedback through websocket rpc", () => Effect.gen(function* () { const input = { @@ -6239,6 +6350,98 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("subscribeServerLifecycle buffers updates published during snapshot capture", () => + Effect.gen(function* () { + const pubsub = yield* PubSub.unbounded(); + const streamSubscribed = yield* Deferred.make(); + const snapshotPublished = yield* Deferred.make(); + const bootstrapProjectId = ProjectId.make("project-bootstrap"); + const bootstrapThreadId = ThreadId.make("thread-bootstrap"); + const snapshotEvent = { + version: 1 as const, + sequence: 1, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "pending" as const, + }, + }; + const gapEvent = { + version: 1 as const, + sequence: 2, + type: "welcome" as const, + payload: { + environment: testEnvironmentDescriptor, + cwd: "/tmp/project", + projectName: "project", + bootstrapStatus: "complete" as const, + bootstrapProjectId, + bootstrapThreadId, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + }, + }; + const sentinelEvent = { + version: 1 as const, + sequence: 3, + type: "ready" as const, + payload: { at: "2026-01-01T00:00:01.000Z", environment: testEnvironmentDescriptor }, + }; + const liveStream = Stream.unwrap( + Effect.gen(function* () { + const subscription = yield* PubSub.subscribe(pubsub); + yield* Deferred.succeed(streamSubscribed, undefined); + return Stream.fromSubscription(subscription); + }), + ); + + yield* buildAppUnderTest({ + layers: { + serverLifecycleEvents: { + snapshot: PubSub.publish(pubsub, gapEvent).pipe( + Effect.andThen(Deferred.succeed(snapshotPublished, undefined)), + Effect.as({ sequence: 1, events: [snapshotEvent] }), + ), + stream: liveStream, + }, + }, + }); + + yield* Effect.gen(function* () { + yield* Deferred.await(snapshotPublished); + yield* Deferred.await(streamSubscribed); + yield* PubSub.publish(pubsub, sentinelEvent); + }).pipe(Effect.forkScoped); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerLifecycle]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "welcome"); + assert.equal(first?.sequence, 1); + if (first?.type !== "welcome") { + assert.fail("expected the pending bootstrap event"); + } + assert.equal(first.payload.bootstrapStatus, "pending"); + assert.equal(second?.type, "welcome"); + assert.equal(second?.sequence, 2); + if (second?.type !== "welcome") { + assert.fail("expected the bootstrap completion event"); + } + assert.equal(second.payload.bootstrapStatus, "complete"); + assert.equal(second.payload.bootstrapProjectId, bootstrapProjectId); + assert.equal(second.payload.bootstrapThreadId, bootstrapThreadId); + assert.equal(second.payload.bootstrapProjectCreated, true); + assert.equal(second.payload.bootstrapThreadCreated, true); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.searchEntries", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 2c95a6163acd..e46c2a8ca010 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -151,6 +151,7 @@ it.effect("marks active running sessions that have persisted resume state", () = ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -265,6 +266,7 @@ it.effect.each(["marked update", "opt-in restart"] as const)( firstMarkerCleared ? Deferred.succeed(continuationCleared, undefined) : Effect.void, ), ), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -391,6 +393,7 @@ it.effect("does not continue archived or deleted marked sessions", () => { ); }, upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -446,6 +449,7 @@ it.effect("retries continuation preparation before settling a persistent failure }), ), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -517,6 +521,7 @@ it.effect("reconciles multiple active and archived orphans but skips live sessio ), ), upsert: (binding) => Effect.sync(() => upserts.push(binding)), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -596,6 +601,7 @@ it.effect( }), ), upsert: () => Effect.fail(writeFailure), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -633,6 +639,7 @@ it.effect("retries failed projections and continues after a persistent failure", directory: { getBinding: () => Effect.succeed(Option.none()), upsert: () => Effect.void, + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -682,6 +689,7 @@ it.effect("does not fail startup when the live provider session inventory cannot Effect.provideService(ProviderSessionDirectory.ProviderSessionDirectory, { getBinding: () => Effect.die("unused"), upsert: () => Effect.die("unused"), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -754,6 +762,7 @@ for (const scenario of [ Effect.sync(() => { upserts.push(binding); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), @@ -827,6 +836,7 @@ for (const preparedStatus of [ if (binding.status !== "starting" || sends.length === 0) return; yield* Deferred.succeed(cleared, undefined); }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => @@ -932,6 +942,7 @@ it.effect("settles failed opt-in recovery without retrying the provider turn", ( Effect.sync(() => { binding = next; }), + recordImportedTranscript: () => Effect.die("unused"), getProvider: () => Effect.die("unused"), listThreadIds: () => Effect.die("unused"), listBindings: () => Effect.succeed([]), diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index fddf618cb13b..8cba52552270 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -155,6 +155,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.some(bootstrapThreadId)), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -181,6 +182,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa assert.deepStrictEqual(targets, { bootstrapProjectId, bootstrapThreadId, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, }); assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }); @@ -212,6 +215,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -237,6 +241,8 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), @@ -250,6 +256,60 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }), ); +it.effect( + "resolveAutoBootstrapWelcomeTargets preserves a project created before thread failure", + () => + Effect.gen(function* () { + const dispatchCalls = yield* Ref.make>([]); + const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(ServerConfig.ServerConfig, { + cwd: "/tmp/startup-project", + autoBootstrapProjectFromCwd: true, + } as never), + Effect.provideService(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("thread lookup failed"), + getImportedAgentSessionSources: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.succeed(Option.none()), + getFullThreadDiffContext: () => Effect.succeed(Option.none()), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.succeed({ matches: [] }), + }), + Effect.provideService(OrchestrationEngine.OrchestrationEngineService, { + readEvents: () => Stream.empty, + readThreadEvents: () => Stream.empty, + getThreadReplayStats: () => Effect.die("unused thread replay stats"), + dispatch: (command) => + Ref.update(dispatchCalls, (calls) => [...calls, command.type]).pipe( + Effect.as({ sequence: 1 }), + ), + streamDomainEvents: Stream.empty, + subscribeDomainEvents: Effect.succeed(Stream.empty), + latestSequence: Effect.succeed(0), + } satisfies OrchestrationEngine.OrchestrationEngineService["Service"]), + Effect.provide(NodeServices.layer), + ); + + assert.equal(typeof targets.bootstrapProjectId, "string"); + assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapThreadId, undefined); + assert.equal(targets.bootstrapThreadCreated, undefined); + assert.deepStrictEqual(yield* Ref.get(dispatchCalls), ["project.create"]); + }), +); + it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation failures", () => Effect.gen(function* () { const crypto = yield* Crypto.Crypto; @@ -278,6 +338,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), + getImportedAgentSessionSources: () => Effect.die("unused"), getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), @@ -309,3 +370,31 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa assert.deepStrictEqual(yield* Ref.get(dispatchCalls), []); }).pipe(Effect.provide(NodeServices.layer)), ); + +it.effect("completeAutoBootstrapWelcome settles failures without bootstrap targets", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.fail("bootstrap failed"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles unexpected defects", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome( + Effect.die("bootstrap defect"), + ); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); + +it.effect("completeAutoBootstrapWelcome settles an empty bootstrap result", () => + Effect.gen(function* () { + const completion = yield* ServerRuntimeStartup.completeAutoBootstrapWelcome(Effect.succeed({})); + + assert.deepStrictEqual(completion, { bootstrapStatus: "complete" }); + }), +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index ea3670f08c9f..2a12dbb3637c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -194,6 +194,8 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapProjectId: ProjectId | undefined; let bootstrapThreadId: ThreadId | undefined; + let bootstrapProjectCreated = false; + let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { yield* Effect.gen(function* () { @@ -216,45 +218,79 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { workspaceRoot: serverConfig.cwd, createdAt, }); + bootstrapProjectId = nextProjectId; + bootstrapProjectCreated = true; } else { nextProjectId = existingProject.value.id; + bootstrapProjectId = nextProjectId; nextThreadModelSelection = existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); } - const existingThreadId = - yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); - if (Option.isNone(existingThreadId)) { - const createdAt = DateTime.formatIso(yield* DateTime.now); - const createdThreadId = ThreadId.make(yield* randomUUID); - yield* orchestrationEngine.dispatch({ - type: "thread.create", - commandId: CommandId.make(yield* randomUUID), - threadId: createdThreadId, - projectId: nextProjectId, - title: "New thread", - modelSelection: nextThreadModelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "full-access", - branch: null, - worktreePath: null, - createdAt, - }); - bootstrapProjectId = nextProjectId; - bootstrapThreadId = createdThreadId; - } else { - bootstrapProjectId = nextProjectId; - bootstrapThreadId = existingThreadId.value; - } + yield* Effect.gen(function* () { + const existingThreadId = + yield* projectionReadModelQuery.getFirstActiveThreadIdByProjectId(nextProjectId); + if (Option.isNone(existingThreadId)) { + const createdAt = DateTime.formatIso(yield* DateTime.now); + const createdThreadId = ThreadId.make(yield* randomUUID); + yield* orchestrationEngine.dispatch({ + type: "thread.create", + commandId: CommandId.make(yield* randomUUID), + threadId: createdThreadId, + projectId: nextProjectId, + title: "New thread", + modelSelection: nextThreadModelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + }); + bootstrapThreadId = createdThreadId; + bootstrapThreadCreated = true; + } else { + bootstrapThreadId = existingThreadId.value; + } + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup thread auto-bootstrap failed", { + bootstrapProjectId: nextProjectId, + cause, + }), + ), + ); }); } return { ...(bootstrapProjectId ? { bootstrapProjectId } : {}), ...(bootstrapThreadId ? { bootstrapThreadId } : {}), + ...(bootstrapProjectId ? { bootstrapProjectCreated } : {}), + ...(bootstrapThreadId ? { bootstrapThreadCreated } : {}), } as const; }); +export const completeAutoBootstrapWelcome = ( + bootstrap: Effect.Effect, +) => + bootstrap.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => + Cause.hasInterrupts(cause) + ? Effect.failCause(cause) + : Effect.logWarning("startup auto-bootstrap failed", { cause }).pipe( + Effect.as({ bootstrapStatus: "complete" as const }), + ), + onSuccess: (targets) => + Effect.succeed({ + ...targets, + bootstrapStatus: "complete" as const, + }), + }), + ); + const resolveStartupBrowserTarget = Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; @@ -839,36 +875,31 @@ export const make = (options?: StartupOptions) => runStartupPhase( "welcome.autobootstrap", Effect.gen(function* () { - const bootstrapTargets = yield* resolveAutoBootstrapWelcomeTargets.pipe( - Effect.provideService(Crypto.Crypto, crypto), + const bootstrapCompletion = yield* completeAutoBootstrapWelcome( + resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provideService(Crypto.Crypto, crypto), + ), + ); + + yield* Effect.logDebug( + "startup phase: publishing completed bootstrap welcome event", + { + environmentId: environment.environmentId, + cwd: welcomeBase.cwd, + projectName: welcomeBase.projectName, + ...bootstrapCompletion, + }, ); - if (!bootstrapTargets.bootstrapProjectId && !bootstrapTargets.bootstrapThreadId) { - return; - } - - yield* Effect.logDebug("startup phase: publishing bootstrapped welcome event", { - environmentId: environment.environmentId, - cwd: welcomeBase.cwd, - projectName: welcomeBase.projectName, - bootstrapProjectId: bootstrapTargets.bootstrapProjectId, - bootstrapThreadId: bootstrapTargets.bootstrapThreadId, - }); yield* lifecycleEvents.publish({ version: 1, type: "welcome", payload: { environment, ...welcomeBase, - ...bootstrapTargets, + ...bootstrapCompletion, }, }); - }).pipe( - Effect.catch((cause) => - Effect.logWarning("startup auto-bootstrap welcome failed", { - cause, - }), - ), - ), + }).pipe(Effect.ignoreCause({ log: true })), ), ); } @@ -914,7 +945,11 @@ export const make = (options?: StartupOptions) => lifecycleEvents.publish({ version: 1, type: "welcome", - payload: { environment, ...welcomeBase }, + payload: { + environment, + ...welcomeBase, + bootstrapStatus: serverConfig.autoBootstrapProjectFromCwd ? "pending" : "complete", + }, }), ); yield* options?.activate ?? Effect.void; diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 4526e2c988a4..6e837308e8cb 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -14,6 +14,7 @@ import * as Duration from "effect/Duration"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; @@ -22,6 +23,7 @@ import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as ServerConfig from "./config.ts"; import { SqlitePersistenceMemory } from "./persistence/Layers/Sqlite.ts"; import * as ServerSettingsModule from "./serverSettings.ts"; +import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.ts"; const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); @@ -1102,4 +1104,39 @@ it.layer(NodeServices.layer)("server settings", (it) => { ); }).pipe(Effect.provide(makeServerSettingsLayer())), ); + + it.effect("materializes provider secrets for terminal environment resolution", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const instanceId = ProviderInstanceId.make("codex_terminal"); + + yield* serverSettings.updateSettings({ + providerInstances: { + [instanceId]: { + driver: ProviderDriverKind.make("codex"), + environment: [ + { name: "OPENROUTER_API_KEY", value: "sk-terminal-secret", sensitive: true }, + ], + config: { homePath: "~/.codex-terminal" }, + }, + }, + }); + + const environment = yield* resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: instanceId, + env: undefined, + }); + const persisted = yield* fileSystem.readFileString(serverConfig.settingsPath); + + assert.equal(environment.OPENROUTER_API_KEY, "sk-terminal-secret"); + assert.match(environment.CODEX_HOME ?? "", /[\\/][.]codex-terminal$/); + assert.notInclude(persisted, "sk-terminal-secret"); + assert.include(persisted, '"valueRedacted": true'); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); }); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index deea39631788..e480e11588b0 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -7,9 +7,14 @@ import { type TerminalMetadataStreamEvent, type TerminalOpenInput, type TerminalRestartInput, + ProviderDriverKind, + ProviderInstanceId, + ServerSettingsError, + TerminalProviderInstanceNotFoundError, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Data from "effect/Data"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; @@ -23,11 +28,16 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schedule from "effect/Schedule"; import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -215,6 +225,9 @@ interface CreateManagerOptions { maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; + resolveProviderInstanceEnvironment?: Parameters< + typeof TerminalManager.makeWithOptions + >[0]["resolveProviderInstanceEnvironment"]; } interface ManagerFixture { @@ -259,6 +272,9 @@ const createManager = ( ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), + ...(options.resolveProviderInstanceEnvironment !== undefined + ? { resolveProviderInstanceEnvironment: options.resolveProviderInstanceEnvironment } + : {}), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -1736,6 +1752,26 @@ it.layer( }), ); + it.effect("expands provider home paths passed to setup terminals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5); + + yield* manager.open({ + ...openInput(), + env: { + CODEX_HOME: "~/.codex-work", + CLAUDE_CONFIG_DIR: "~/.claude-work", + CUSTOM_ACCOUNT: "~/leave-this-value-alone", + }, + }); + + const environment = ptyAdapter.spawnInputs[0]?.env; + expect(environment?.CODEX_HOME).toMatch(/[\\/][.]codex-work$/); + expect(environment?.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-work$/); + expect(environment?.CUSTOM_ACCOUNT).toBe("~/leave-this-value-alone"); + }), + ); + it.effect("strips AppImage runtime env from terminal sessions", () => Effect.gen(function* () { const appDir = "/tmp/.mount_T3Codeabc123"; @@ -1822,6 +1858,382 @@ it.layer( }), ); + it.effect("resolves a provider instance environment before spawning", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { T3CODE_SECRET: "server-only" }, + resolveProviderInstanceEnvironment: (requestedId, env) => + Effect.succeed({ + ...env, + PROVIDER_SECRET: requestedId === providerInstanceId ? "secret-value" : "wrong", + CODEX_HOME: "/accounts/codex-work", + }), + }); + + const snapshot = yield* manager.open( + openInput({ providerInstanceId, env: { CLIENT_FLAG: "1" } }), + ); + + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("secret-value"); + expect(ptyAdapter.spawnInputs[0]?.env.CODEX_HOME).toBe("/accounts/codex-work"); + expect(ptyAdapter.spawnInputs[0]?.env.CLIENT_FLAG).toBe("1"); + expect(ptyAdapter.spawnInputs[0]?.env.T3CODE_SECRET).toBeUndefined(); + expect(snapshot).not.toHaveProperty("env"); + expect(snapshot).not.toHaveProperty("providerInstanceId"); + }), + ); + + it.effect("fails closed when a provider instance is missing", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager.open(openInput({ providerInstanceId })).pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + + it.effect("preserves the settings failure when provider environment resolution fails", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_work"); + const settingsCause = new Error("secret store read failed"); + const settingsError = new ServerSettingsError({ + settingsPath: "/test/settings.json", + operation: "read-secret", + providerInstanceId, + environmentVariable: "OPENROUTER_API_KEY", + cause: settingsCause, + }); + const serverSettings = ServerSettings.ServerSettingsService.of({ + start: Effect.void, + ready: Effect.void, + getSettings: Effect.fail(settingsError), + updateSettings: () => Effect.fail(settingsError), + streamChanges: Stream.empty, + subscribeChanges: Effect.succeed(Stream.empty), + }); + + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: providerInstanceId, + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId, + }); + expect(error.cause).toBe(settingsError); + expect(error.message).not.toContain(settingsError.message); + expect(error.message).not.toContain("OPENROUTER_API_KEY"); + }), + ); + + it.effect.each([ + { + name: "Codex home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex" }, + expectedHome: "/configured/codex", + }, + { + name: "Codex shadow home", + driver: "codex", + variable: "CODEX_HOME", + config: { homePath: "/configured/codex", shadowHomePath: "/configured/codex-shadow" }, + expectedHome: "/configured/codex-shadow", + }, + { + name: "Claude home", + driver: "claudeAgent", + variable: "CLAUDE_CONFIG_DIR", + config: { homePath: "/configured/claude" }, + expectedHome: "/configured/claude", + }, + ])("prefers $name over the instance environment", ({ driver, variable, config, expectedHome }) => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "configured_home", + env: undefined, + }); + + expect(environment[variable]).toBe(path.resolve(expectedHome)); + }).pipe( + Effect.provide( + ServerSettings.layerTest({ + providerInstances: { + [ProviderInstanceId.make("configured_home")]: { + driver: ProviderDriverKind.make(driver), + environment: [{ name: variable, value: "~/.environment-account", sensitive: false }], + config, + }, + }, + }), + ), + ), + ); + + it.effect("resolves the legacy Codex default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { codex: { homePath: "~/.codex-legacy" } }, + }), + ), + ), + ); + + it.effect("resolves the legacy Claude default instance", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "claudeAgent", + env: undefined, + }); + + expect(environment.CLAUDE_CONFIG_DIR).toMatch(/[\\/][.]claude-legacy$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providerInstances: {}, + providers: { claudeAgent: { homePath: "~/.claude-legacy" } }, + }), + ), + ), + ); + + it.effect("prefers an explicit default instance over legacy provider settings", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const environment = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex", + env: undefined, + }); + + expect(environment.CODEX_HOME).toMatch(/[\\/][.]codex-explicit$/); + }).pipe( + Effect.provide( + ServerSettings.ServerSettingsService.layerTest({ + providers: { codex: { homePath: "~/.codex-legacy" } }, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: "codex", + config: { homePath: "~/.codex-explicit" }, + }, + }, + }), + ), + ), + ); + + it.effect("keeps unknown provider instance ids unavailable after legacy hydration", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const error = yield* TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId: "codex_unknown", + env: undefined, + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + _tag: "TerminalProviderInstanceNotFoundError", + providerInstanceId: "codex_unknown", + }); + }).pipe(Effect.provide(ServerSettings.ServerSettingsService.layerTest())), + ); + + it.effect("restarts a running terminal when the resolved provider environment changes", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerSecret = "first-secret"; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: () => + Effect.succeed({ PROVIDER_SECRET: providerSecret }), + }); + + yield* manager.open(openInput({ providerInstanceId })); + providerSecret = "second-secret"; + yield* manager.open(openInput({ providerInstanceId })); + + expect(ptyAdapter.processes[0]?.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env.PROVIDER_SECRET).toBe("second-secret"); + }), + ); + + it.effect("restarts with current provider secrets and clears bounded history", () => + Effect.gen(function* () { + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const providerInstanceId = ProviderInstanceId.make("codex_restart"); + const { manager, ptyAdapter, logsDir } = yield* createManager(2, { + historyByteLimit: 8, + resolveProviderInstanceEnvironment: (rawProviderInstanceId, env) => + TerminalManager.resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + }); + const homePath = path.join(logsDir, "codex"); + const updateSecret = (value: string) => + serverSettings.updateSettings({ + providerInstances: { + [providerInstanceId]: { + driver: ProviderDriverKind.make("codex"), + config: { homePath }, + environment: [{ name: "PROVIDER_SECRET", value, sensitive: true }], + }, + }, + }); + const input = { + providerInstanceId, + env: { CLIENT_FLAG: "1", PROVIDER_SECRET: "client-value" }, + }; + const outputProcessed = yield* Deferred.make(); + const unsubscribe = yield* manager.subscribe((event) => + event.type === "output" + ? Deferred.succeed(outputProcessed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + yield* updateSecret("first-secret"); + yield* manager.restart(restartInput(input)); + const firstProcess = ptyAdapter.processes[0]!; + expect(ptyAdapter.spawnInputs[0]?.env.PROVIDER_SECRET).toBe("first-secret"); + firstProcess.emitData("discarded\nold-one\nold-two\n"); + yield* Deferred.await(outputProcessed); + expect((yield* manager.open(openInput(input))).history).toBe("old-two\n"); + + yield* updateSecret("second-secret"); + const restarted = yield* manager.restart(restartInput(input)); + + expect(firstProcess.killed).toBe(true); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + PROVIDER_SECRET: "second-secret", + CODEX_HOME: homePath, + CLIENT_FLAG: "1", + }); + expect(restarted.history).toBe(""); + expect(restarted.status).toBe("running"); + expect(restarted).not.toHaveProperty("env"); + expect(restarted).not.toHaveProperty("providerInstanceId"); + const logPath = yield* historyLogPath(logsDir); + expect(yield* readFileString(logPath)).toBe(""); + + ptyAdapter.processes[1]!.emitData("discarded again\nnew-one\nnew-two\n"); + yield* manager.close({ threadId: "thread-1" }); + expect(yield* readFileString(logPath)).toBe("new-two\n"); + }).pipe( + Effect.provide( + ServerSettings.layer.pipe( + Layer.provide(ServerSecretStore.layer), + Layer.provide(SqlitePersistenceMemory), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3code-terminal-provider-restart-" }), + ), + ), + ), + ), + ); + + it.effect("attaches to a running provider terminal without resolving the provider again", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("codex_work"); + let providerAvailable = true; + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + providerAvailable + ? Effect.succeed({ PROVIDER_SECRET: "secret-value" }) + : Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + yield* manager.open(openInput({ providerInstanceId })); + providerAvailable = false; + const events: TerminalAttachStreamEvent[] = []; + + const unsubscribe = yield* manager.attachStream( + { ...openInput({ providerInstanceId }), restartIfNotRunning: true }, + (event) => Effect.sync(() => events.push(event)), + ); + unsubscribe(); + + expect(events[0]?.type).toBe("snapshot"); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(ptyAdapter.processes[0]?.killed).toBe(false); + }), + ); + + it.effect("fails closed when attaching would create a missing provider terminal", () => + Effect.gen(function* () { + const providerInstanceId = ProviderInstanceId.make("deleted_instance"); + const { manager, ptyAdapter } = yield* createManager(5, { + resolveProviderInstanceEnvironment: (requestedId) => + Effect.fail( + new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(requestedId), + }), + ), + }); + + const error = yield* manager + .attachStream(openInput({ providerInstanceId }), () => Effect.void) + .pipe(Effect.flip); + + assert.deepStrictEqual( + error, + new TerminalProviderInstanceNotFoundError({ providerInstanceId }), + ); + expect(ptyAdapter.spawnInputs).toHaveLength(0); + }), + ); + it.effect("starts zsh with prompt spacer disabled to avoid `%` end markers", () => Effect.gen(function* () { if ((yield* HostProcessPlatform) === "win32") return; diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index f04e3c2d897b..d9bdc6bcd92a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -15,6 +15,8 @@ import { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -31,6 +33,9 @@ import { type TerminalSessionStatus, type TerminalSummary, type TerminalWriteInput, + ClaudeSettings, + CodexSettings, + ProviderInstanceId, } from "@t3tools/contracts"; import { makeKeyedCoalescingWorker } from "@t3tools/shared/KeyedCoalescingWorker"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; @@ -52,11 +57,17 @@ import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; +import { mergeProviderInstanceEnvironment } from "../provider/ProviderInstanceEnvironment.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; +import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; +import * as ServerSettings from "../serverSettings.ts"; import { increment, terminalRestartsTotal, terminalSessionsTotal, } from "../observability/Metrics.ts"; +import { expandHomePath } from "../pathExpansion.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as PortScanner from "../preview/PortScanner.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -69,6 +80,8 @@ export { TerminalError, TerminalHistoryError, TerminalNotRunningError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalResizeError, TerminalSessionLookupError, TerminalWriteError, @@ -86,6 +99,8 @@ const DEFAULT_OPEN_ROWS = 30; const TERMINAL_ENV_BLOCKLIST = new Set(["PORT", "ELECTRON_RENDERER_PORT", "ELECTRON_RUN_AS_NODE"]); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const MAX_TERMINAL_LABEL_LENGTH = 128; +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); class TerminalSubprocessCheckError extends Schema.TaggedErrorClass()( "TerminalSubprocessCheckError", @@ -1267,7 +1282,8 @@ function createTerminalSpawnEnv( } if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { - spawnEnv[key] = value; + spawnEnv[key] = + key === "CODEX_HOME" || key === "CLAUDE_CONFIG_DIR" ? expandHomePath(value) : value; } } // Both PTY backends feed truecolor-capable terminal clients. @@ -1306,17 +1322,78 @@ interface TerminalManagerOptions { readonly threadId: string; readonly terminalId: string; }) => Effect.Effect; + resolveProviderInstanceEnvironment?: ( + providerInstanceId: string, + env: Record | undefined, + ) => Effect.Effect< + Record, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + >; } +export const resolveProviderInstanceTerminalEnvironment = Effect.fn( + "terminal.resolveProviderInstanceTerminalEnvironment", +)(function* (input: { + readonly serverSettings: ServerSettings.ServerSettingsService["Service"]; + readonly path: Path.Path; + readonly rawProviderInstanceId: string; + readonly env: Record | undefined; +}) { + const providerInstanceId = ProviderInstanceId.make(input.rawProviderInstanceId); + const settings = yield* input.serverSettings.getSettings.pipe( + Effect.mapError((cause) => new TerminalProviderEnvironmentError({ providerInstanceId, cause })), + ); + const instance = deriveProviderInstanceConfigMap(settings)[providerInstanceId]; + if (instance === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ providerInstanceId }); + } + + let resolved = mergeProviderInstanceEnvironment(instance.environment, input.env ?? {}); + if (instance.driver === "codex") { + const config = decodeCodexSettings(instance.config ?? {}); + if (Option.isSome(config)) { + const layout = yield* resolveCodexHomeLayout(config.value).pipe( + Effect.provideService(Path.Path, input.path), + ); + if (layout.effectiveHomePath) + resolved = { ...resolved, CODEX_HOME: layout.effectiveHomePath }; + } + } else if (instance.driver === "claudeAgent") { + const config = decodeClaudeSettings(instance.config ?? {}); + if (Option.isSome(config)) { + resolved = yield* makeClaudeEnvironment(config.value, resolved).pipe( + Effect.provideService(Path.Path, input.path), + ); + } + } + + return Object.fromEntries( + Object.entries(resolved).filter((entry): entry is [string, string] => entry[1] !== undefined), + ); +}); + export const make = Effect.fn("TerminalManager.make")(function* () { const { terminalLogsDir } = yield* ServerConfig.ServerConfig; const ptyAdapter = yield* PtyAdapter.PtyAdapter; const portDiscovery = yield* PortScanner.PortDiscovery; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const path = yield* Path.Path; + const resolveProviderInstanceEnvironment = Effect.fn( + "terminal.resolveProviderInstanceEnvironment", + )((rawProviderInstanceId: string, env: Record | undefined) => + resolveProviderInstanceTerminalEnvironment({ + serverSettings, + path, + rawProviderInstanceId, + env, + }), + ); return yield* makeWithOptions({ logsDir: terminalLogsDir, ptyAdapter, registerTerminalProcesses: portDiscovery.registerTerminalProcesses, unregisterTerminal: portDiscovery.unregisterTerminal, + resolveProviderInstanceEnvironment, }); }); @@ -1339,6 +1416,24 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( + function* ( + input: Input, + ): Effect.fn.Return< + Input, + TerminalProviderInstanceNotFoundError | TerminalProviderEnvironmentError + > { + if (input.providerInstanceId === undefined) return input; + const resolver = options.resolveProviderInstanceEnvironment; + if (resolver === undefined) { + return yield* new TerminalProviderInstanceNotFoundError({ + providerInstanceId: ProviderInstanceId.make(input.providerInstanceId), + }); + } + const env = yield* resolver(input.providerInstanceId, input.env); + return { ...input, env }; + }, + ); // One process-table snapshot per poll tick, shared across every terminal. // Per-terminal `pgrep`/`ps` calls multiply spawn load by terminal count and // can exhaust the PID space on hosts with many sessions (#6332). @@ -2468,7 +2563,10 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); const open: TerminalManager["Service"]["open"] = (input) => - withThreadLock(input.threadId, openLocked(input)); + withThreadLock( + input.threadId, + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), + ); const openOrAttachForStream = (input: TerminalAttachInput) => withThreadLock( @@ -2485,11 +2583,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); } - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } const session = existing.value; @@ -2497,11 +2596,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const targetRows = input.rows ?? session.rows; if (!session.process && input.cwd && input.restartIfNotRunning === true) { - return yield* openLocked({ + const resolvedInput = yield* resolveLaunchInputEnvironment({ ...input, terminalId, cwd: input.cwd, }); + return yield* openLocked(resolvedInput); } if ( @@ -2753,84 +2853,87 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }), ); + const restartResolved = (input: TerminalRestartInput) => + Effect.gen(function* () { + yield* increment(terminalRestartsTotal, { scope: "thread" }); + const terminalId = input.terminalId; + yield* assertValidCwd(input.cwd); + + const sessionKey = toSessionKey(input.threadId, terminalId); + const existingSession = yield* getSession(input.threadId, terminalId); + let session: TerminalSessionState; + if (Option.isNone(existingSession)) { + const cols = input.cols ?? DEFAULT_OPEN_COLS; + const rows = input.rows ?? DEFAULT_OPEN_ROWS; + session = { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + worktreePath: input.worktreePath ?? null, + status: "starting", + pid: null, + history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), + pendingHistoryControlSequence: "", + pendingProcessEvents: [], + pendingProcessEventIndex: 0, + processEventDrainRunning: false, + exitCode: null, + exitSignal: null, + updatedAt: yield* nowIso, + eventSequence: 0, + cols, + rows, + process: null, + unsubscribeData: null, + unsubscribeExit: null, + hasRunningSubprocess: false, + childCommandLabel: null, + runtimeEnv: normalizedRuntimeEnv(input.env), + }; + const createdSession = session; + yield* modifyManagerState((state) => { + const sessions = new Map(state.sessions); + sessions.set(sessionKey, createdSession); + return [undefined, { ...state, sessions }] as const; + }); + yield* evictInactiveSessionsIfNeeded(); + } else { + session = existingSession.value; + yield* stopProcess(session); + session.cwd = input.cwd; + session.worktreePath = input.worktreePath ?? null; + session.runtimeEnv = normalizedRuntimeEnv(input.env); + } + + const cols = input.cols ?? session.cols; + const rows = input.rows ?? session.rows; + + session.history.clear(); + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + yield* persistHistory(input.threadId, terminalId, session.history); + yield* startSession( + session, + { + threadId: input.threadId, + terminalId, + cwd: input.cwd, + ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), + cols, + rows, + ...(input.env ? { env: input.env } : {}), + }, + "restarted", + ); + return snapshot(session); + }); + const restart: TerminalManager["Service"]["restart"] = (input) => withThreadLock( input.threadId, - Effect.gen(function* () { - yield* increment(terminalRestartsTotal, { scope: "thread" }); - const terminalId = input.terminalId; - yield* assertValidCwd(input.cwd); - - const sessionKey = toSessionKey(input.threadId, terminalId); - const existingSession = yield* getSession(input.threadId, terminalId); - let session: TerminalSessionState; - if (Option.isNone(existingSession)) { - const cols = input.cols ?? DEFAULT_OPEN_COLS; - const rows = input.rows ?? DEFAULT_OPEN_ROWS; - session = { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - worktreePath: input.worktreePath ?? null, - status: "starting", - pid: null, - history: new BoundedTerminalHistory(historyLineLimit, "", historyByteLimit), - pendingHistoryControlSequence: "", - pendingProcessEvents: [], - pendingProcessEventIndex: 0, - processEventDrainRunning: false, - exitCode: null, - exitSignal: null, - updatedAt: yield* nowIso, - eventSequence: 0, - cols, - rows, - process: null, - unsubscribeData: null, - unsubscribeExit: null, - hasRunningSubprocess: false, - childCommandLabel: null, - runtimeEnv: normalizedRuntimeEnv(input.env), - }; - const createdSession = session; - yield* modifyManagerState((state) => { - const sessions = new Map(state.sessions); - sessions.set(sessionKey, createdSession); - return [undefined, { ...state, sessions }] as const; - }); - yield* evictInactiveSessionsIfNeeded(); - } else { - session = existingSession.value; - yield* stopProcess(session); - session.cwd = input.cwd; - session.worktreePath = input.worktreePath ?? null; - session.runtimeEnv = normalizedRuntimeEnv(input.env); - } - - const cols = input.cols ?? session.cols; - const rows = input.rows ?? session.rows; - - session.history.clear(); - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - yield* persistHistory(input.threadId, terminalId, session.history); - yield* startSession( - session, - { - threadId: input.threadId, - terminalId, - cwd: input.cwd, - ...(input.worktreePath !== undefined ? { worktreePath: input.worktreePath } : {}), - cols, - rows, - ...(input.env ? { env: input.env } : {}), - }, - "restarted", - ); - return snapshot(session); - }), + resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)), ); const close: TerminalManager["Service"]["close"] = (input) => diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 5ecdd341c952..6261f7bc5287 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -53,6 +53,7 @@ import { type RelayClientInstallProgressEvent, ServerSelfUpdateError, type ServerSelfUpdateProgressEvent, + type ServerLifecycleStreamEvent, type FilesystemBrowseFailure, FilesystemBrowseError, AssetWorkspaceContextNotFoundError, @@ -96,6 +97,7 @@ import { } from "./observability/RpcInstrumentation.ts"; import * as ProviderRegistry from "./provider/Services/ProviderRegistry.ts"; import * as ProviderService from "./provider/Services/ProviderService.ts"; +import * as ProviderSessionDirectory from "./provider/Services/ProviderSessionDirectory.ts"; import * as ProviderMaintenanceRunner from "./provider/providerMaintenanceRunner.ts"; import { ProviderAuthService } from "./provider/Services/ProviderAuthService.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -119,6 +121,8 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; +import { importRecentAgentThreads } from "./project/AgentSessionImporter.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as RemoteOpenTargets from "./environment/RemoteOpenTargets.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; @@ -512,6 +516,7 @@ const makeWsRpcLayer = ( const portDiscovery = yield* PortScanner.PortDiscovery; const providerRegistry = yield* ProviderRegistry.ProviderRegistry; const providerService = yield* ProviderService.ProviderService; + const providerSessionDirectory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; const providerAuth = yield* ProviderAuthService; const providerInstances = yield* ProviderInstanceRegistry; @@ -560,6 +565,7 @@ const makeWsRpcLayer = ( return true; }); const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -2332,6 +2338,31 @@ const makeWsRpcLayer = ( deletePendingAttachment(input.attachmentId), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), + [WS_METHODS.agentSessionsImport]: (input) => + observeRpcEffect( + WS_METHODS.agentSessionsImport, + importRecentAgentThreads(input).pipe( + Effect.provideService(AgentSessionScanner.AgentSessionScanner, agentSessionScanner), + Effect.provideService( + OrchestrationEngine.OrchestrationEngineService, + orchestrationEngine, + ), + Effect.provideService( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + projectionSnapshotQuery, + ), + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService( + ProviderSessionDirectory.ProviderSessionDirectory, + providerSessionDirectory, + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2746,11 +2777,18 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerLifecycle, Effect.gen(function* () { + const liveBuffer = yield* Queue.unbounded(); + yield* Effect.forkScoped( + lifecycleEvents.stream.pipe( + Stream.runForEach((event) => Queue.offer(liveBuffer, event)), + ), + { startImmediately: true }, + ); const snapshot = yield* lifecycleEvents.snapshot; const snapshotEvents = Array.from(snapshot.events).toSorted( (left, right) => left.sequence - right.sequence, ); - const liveEvents = lifecycleEvents.stream.pipe( + const liveEvents = Stream.fromQueue(liveBuffer).pipe( Stream.filter((event) => event.sequence > snapshot.sequence), ); return Stream.concat(Stream.fromIterable(snapshotEvents), liveEvents); @@ -2877,6 +2915,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( previewAutomationBroker, ).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), // One server-lifetime service means clients share the same PR caches, and a WS diff --git a/apps/web/src/authBootstrap.test.ts b/apps/web/src/authBootstrap.test.ts index 1a79f729bb03..dfe2b51d0400 100644 --- a/apps/web/src/authBootstrap.test.ts +++ b/apps/web/src/authBootstrap.test.ts @@ -310,6 +310,63 @@ describe("resolveInitialServerAuthGateState", () => { expect(testApi.calls.session).toBe(2); }); + it("keeps manual token submission pending until the session is authenticated", async () => { + vi.useFakeTimers(); + let authenticated = false; + let settled = false; + try { + const testApi = await installAuthApi({ + session: () => + authenticated + ? authenticatedSession(LOOPBACK_AUTH) + : unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { submitServerAuthCredential } = await import("./environments/primary"); + + const submission = submitServerAuthCredential("retry-token").finally(() => { + settled = true; + }); + await vi.advanceTimersByTimeAsync(0); + + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBe(1); + expect(settled).toBe(false); + + authenticated = true; + await vi.advanceTimersByTimeAsync(100); + await expect(submission).resolves.toBeUndefined(); + expect(testApi.calls.session).toBe(2); + } finally { + vi.useRealTimers(); + } + }); + + it("fails manual token submission when the session is not established", async () => { + vi.useFakeTimers(); + try { + const testApi = await installAuthApi({ + session: () => unauthenticatedSession(LOOPBACK_AUTH), + browserSession: () => Effect.succeed(browserSession(["orchestration:read"])), + }); + const { PrimaryEnvironmentAuthSessionTimeoutError, submitServerAuthCredential } = + await import("./environments/primary/auth"); + + const submission = submitServerAuthCredential("retry-token"); + const failure = submission.then( + () => null, + (error: unknown) => error, + ); + await vi.advanceTimersByTimeAsync(2_000); + + await expect(failure).resolves.toBeInstanceOf(PrimaryEnvironmentAuthSessionTimeoutError); + expect(testApi.calls.browserSession).toEqual([{ credential: "retry-token" }]); + expect(testApi.calls.session).toBeGreaterThan(1); + } finally { + vi.useRealTimers(); + } + }); + it("rejects a blank pairing token with a structured validation error", async () => { const { PrimaryEnvironmentPairingCredentialRequiredError, submitServerAuthCredential } = await import("./environments/primary/auth"); diff --git a/apps/web/src/browser/HostedBrowserWebview.test.tsx b/apps/web/src/browser/HostedBrowserWebview.test.tsx new file mode 100644 index 000000000000..4a241befef74 --- /dev/null +++ b/apps/web/src/browser/HostedBrowserWebview.test.tsx @@ -0,0 +1,200 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + FILL_PREVIEW_VIEWPORT, + ThreadId, + type ClientSettings, + type DesktopPreviewBridge, +} from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), + createTab: vi.fn(), + closeTab: vi.fn(), + registerWebview: vi.fn(), + getPreviewConfig: vi.fn(), + activeRecordings: new Set(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); + +vi.mock("~/components/preview/previewBridge", () => ({ + previewBridge: { + createTab: mocks.createTab, + closeTab: mocks.closeTab, + registerWebview: mocks.registerWebview, + getPreviewConfig: mocks.getPreviewConfig, + }, +})); + +vi.mock("~/components/preview/usePreviewBridge", () => ({ + usePreviewBridge: () => undefined, +})); + +vi.mock("./browserRecording", () => ({ + useActiveBrowserRecordingTabIds: () => mocks.activeRecordings, + stopBrowserRecording: async () => null, +})); + +import { + __resetClientSettingsPersistenceForTests, + ensureClientSettingsHydrated, +} from "~/hooks/useSettings"; +import { useBrowserSurfaceStore } from "./browserSurfaceStore"; +import * as desktopTabLifetime from "./desktopTabLifetime"; +import { HostedBrowserWebview } from "./HostedBrowserWebview"; + +let renderer: ReactTestRenderer | undefined; + +function deferred() { + let resolve!: (value: A) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + mocks.getClientSettings.mockReset(); + mocks.setClientSettings.mockReset().mockResolvedValue(undefined); + mocks.createTab.mockReset().mockResolvedValue(undefined); + mocks.closeTab.mockReset().mockResolvedValue(undefined); + mocks.registerWebview.mockReset().mockResolvedValue(undefined); + mocks.getPreviewConfig.mockReset().mockResolvedValue({ + partition: "persist:t3-preview-work", + webPreferences: "contextIsolation=yes", + preloadUrl: null, + }); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", globalThis); + vi.stubGlobal("navigator", { platform: "Linux" }); + vi.stubGlobal( + "requestAnimationFrame", + vi.fn(() => 0), + ); + vi.stubGlobal("cancelAnimationFrame", vi.fn()); + vi.spyOn(console, "error").mockImplementation(() => undefined); +}); + +afterEach(async () => { + vi.useFakeTimers(); + await act(() => renderer?.unmount()); + renderer = undefined; + await vi.advanceTimersByTimeAsync(0); + vi.useRealTimers(); + __resetClientSettingsPersistenceForTests(); + useBrowserSurfaceStore.setState({ activityByTabId: {}, byTabId: {} }); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("HostedBrowserWebview settings hydration", () => { + it("starts a retained background tab only after a settings read succeeds on retry", async () => { + const firstRead = deferred(); + const retryRead = deferred(); + const tabCreation = deferred(); + mocks.getClientSettings + .mockReturnValueOnce(firstRead.promise) + .mockReturnValueOnce(retryRead.promise); + mocks.createTab.mockReturnValueOnce(tabCreation.promise); + const acquire = vi.spyOn(desktopTabLifetime, "acquireDesktopTab"); + const createGuest = vi.fn((_attributes: unknown) => + Object.assign(new EventTarget(), { getWebContentsId: () => 41 }), + ); + const threadRef = { + environmentId: EnvironmentId.make("host-settings-retry"), + threadId: ThreadId.make("thread-settings-retry"), + }; + const runtimeTabId = "retained-background-tab"; + useBrowserSurfaceStore.getState().acquireActivity(runtimeTabId); + + await act(() => { + renderer = create( + , + { + createNodeMock: (element) => + element.type === "webview" + ? createGuest(element.props) + : { scrollLeft: 0, scrollTop: 0, scrollTo: () => undefined }, + }, + ); + }); + + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + const failure = new Error("Saved settings are unavailable"); + await act(async () => { + const hydration = ensureClientSettingsHydrated(); + firstRead.reject(failure); + await expect(hydration).rejects.toBe(failure); + }); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + let retry!: Promise; + await act(() => { + retry = ensureClientSettingsHydrated(); + }); + expect(mocks.getClientSettings).toHaveBeenCalledTimes(2); + expect(acquire).not.toHaveBeenCalled(); + expect(createGuest).not.toHaveBeenCalled(); + expect(mocks.createTab).not.toHaveBeenCalled(); + + await act(async () => { + retryRead.resolve({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + browserDefaultProfileId: "work", + }); + await retry; + }); + + expect(acquire).toHaveBeenCalledExactlyOnceWith(runtimeTabId); + expect(mocks.getPreviewConfig).toHaveBeenCalledExactlyOnceWith(threadRef.environmentId, "work"); + expect(createGuest).toHaveBeenCalledOnce(); + expect(createGuest).toHaveBeenCalledWith( + expect.objectContaining({ + partition: "persist:t3-preview-work", + src: "https://example.com", + }), + ); + expect(mocks.createTab).toHaveBeenCalledExactlyOnceWith(runtimeTabId, { + zoomFactor: 1.25, + colorScheme: "dark", + }); + expect(mocks.registerWebview).not.toHaveBeenCalled(); + + await act(async () => { + tabCreation.resolve(); + await tabCreation.promise; + }); + expect(mocks.registerWebview).toHaveBeenCalledExactlyOnceWith(runtimeTabId, 41); + expect(mocks.closeTab).not.toHaveBeenCalled(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 0f01960ce52b..42d5bcfb35b8 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -6,6 +6,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; +import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; @@ -66,6 +67,7 @@ export function HostedBrowserWebview(props: { zoomFactor, profileId, } = props; + const clientSettingsHydrated = useClientSettingsHydrated(); const config = usePreviewWebviewConfig(threadRef.environmentId, profileId); const [initialSrc] = useState(() => initialUrl ?? "about:blank"); const tabLeaseRef = useRef(null); @@ -94,6 +96,7 @@ export function HostedBrowserWebview(props: { usePreviewBridge({ threadRef, tabId, runtimeTabId }); useEffect(() => { + if (!clientSettingsHydrated) return; crashRecoveryRef.current = INITIAL_WEBVIEW_CRASH_RECOVERY_STATE; const lease = acquireDesktopTab(runtimeTabId); tabLeaseRef.current = lease; @@ -101,7 +104,7 @@ export function HostedBrowserWebview(props: { if (tabLeaseRef.current === lease) tabLeaseRef.current = null; lease.release(); }; - }, [runtimeTabId]); + }, [clientSettingsHydrated, runtimeTabId]); const [webviewGeneration, setWebviewGeneration] = useState(0); const [recoverySrc, setRecoverySrc] = useState(initialSrc); @@ -118,7 +121,7 @@ export function HostedBrowserWebview(props: { useEffect(() => { const webview = webviewRef.current; const bridge = previewBridge; - if (!webview || !config || !bridge) return; + if (!clientSettingsHydrated || !webview || !config || !bridge) return; let disposed = false; let recoveryTimeout: ReturnType | null = null; const register = () => { @@ -164,7 +167,7 @@ export function HostedBrowserWebview(props: { webview.removeEventListener("dom-ready", register); webview.removeEventListener("render-process-gone", recoverGuest); }; - }, [config, initialSrc, runtimeTabId, webviewGeneration]); + }, [clientSettingsHydrated, config, initialSrc, runtimeTabId, webviewGeneration]); const active = presentation.visible && presentation.rect !== null; const lastRect = presentation.rect; @@ -249,7 +252,7 @@ export function HostedBrowserWebview(props: { wrapper.scrollTo({ left: 0, top: 0 }); }, [runtimeTabId, viewport._tag, viewportHeight, viewportWidth]); - if (!config) return null; + if (!clientSettingsHydrated || !config) return null; const renderingActive = active || backgroundActivity || pictureInPicture || recordingActive; const wrapperStyle = resolveHostedBrowserWebviewWrapperStyle({ diff --git a/apps/web/src/browser/browserDefaults.test.ts b/apps/web/src/browser/browserDefaults.test.ts index bac9600c182b..ed86cde1c9c8 100644 --- a/apps/web/src/browser/browserDefaults.test.ts +++ b/apps/web/src/browser/browserDefaults.test.ts @@ -1,15 +1,17 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { DEFAULT_BROWSER_PROFILE_ID, INCOGNITO_BROWSER_PROFILE_ID } from "@t3tools/contracts"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const settings = vi.hoisted(() => ({ current: {} as Record })); vi.mock("~/hooks/useSettings", () => ({ getClientSettings: () => settings.current, useClientSettings: () => undefined, - ensureClientSettingsHydrated: () => Promise.resolve(), + ensureClientSettingsHydrated: vi.fn(async () => undefined), })); -const { getBrowserDefaults } = await import("./browserDefaults"); +const { getBrowserDefaults, resolveBrowserDefaults } = await import("./browserDefaults"); const withDefaultProfile = (browserDefaultProfileId: string) => { settings.current = { @@ -41,3 +43,22 @@ describe("getBrowserDefaults profile resolution", () => { ); }); }); + +describe("resolveBrowserDefaults", () => { + it("rejects failed reads and uses the saved profile after a successful retry", async () => { + withDefaultProfile("work"); + settings.current.browserDefaultZoomFactor = 1.25; + settings.current.browserDefaultAppearance = "dark"; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserDefaults()).rejects.toBe(failure); + await expect(resolveBrowserDefaults()).resolves.toMatchObject({ + viewport: { _tag: "fill" }, + zoomFactor: 1.25, + appearance: "dark", + autoShowFloatingPreview: true, + profileId: "work", + }); + }); +}); diff --git a/apps/web/src/browser/browserDefaults.ts b/apps/web/src/browser/browserDefaults.ts index eaae409568a2..6141b1a52fa9 100644 --- a/apps/web/src/browser/browserDefaults.ts +++ b/apps/web/src/browser/browserDefaults.ts @@ -79,6 +79,7 @@ export function getBrowserDefaults(): BrowserDefaults { * Opening a preview is asynchronous anyway, and before hydration the snapshot * is the schema defaults rather than the user's — a tab opened in that window * would be born at the wrong viewport, zoom and appearance and never corrected. + * Read failures reject so a new tab cannot use the wrong profile or viewport. */ export async function resolveBrowserDefaults(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserLinkTarget.test.ts b/apps/web/src/browser/browserLinkTarget.test.ts index 94f97001c96f..a60362c43bd5 100644 --- a/apps/web/src/browser/browserLinkTarget.test.ts +++ b/apps/web/src/browser/browserLinkTarget.test.ts @@ -1,6 +1,16 @@ -import { describe, expect, it } from "vite-plus/test"; +import type { BrowserLinkTarget } from "@t3tools/contracts"; +import { describe, expect, it, vi } from "vite-plus/test"; -import { resolveLinkTarget } from "./browserLinkTarget"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + +import { resolveBrowserLinkTargetPreference, resolveLinkTarget } from "./browserLinkTarget"; + +const settings = vi.hoisted(() => ({ browserLinkTarget: "system" as BrowserLinkTarget })); + +vi.mock("~/hooks/useSettings", () => ({ + ensureClientSettingsHydrated: vi.fn(async () => undefined), + getClientSettings: () => settings, +})); const click = { metaKey: false, ctrlKey: false }; @@ -67,3 +77,17 @@ describe("resolveLinkTarget", () => { } }); }); + +describe("resolveBrowserLinkTargetPreference", () => { + it.each(["system", "app"] as const)( + "rejects failed reads instead of using the current %s preference", + async (preference) => { + settings.browserLinkTarget = preference; + const failure = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(failure); + + await expect(resolveBrowserLinkTargetPreference()).rejects.toBe(failure); + await expect(resolveBrowserLinkTargetPreference()).resolves.toBe(preference); + }, + ); +}); diff --git a/apps/web/src/browser/browserLinkTarget.ts b/apps/web/src/browser/browserLinkTarget.ts index d03775572747..7ecffb4593d5 100644 --- a/apps/web/src/browser/browserLinkTarget.ts +++ b/apps/web/src/browser/browserLinkTarget.ts @@ -55,6 +55,7 @@ export function isWebUrl(url: string): boolean { * hydration the snapshot is the schema default ("system"), so a link clicked * in the first moments after launch would ignore a persisted "app" — opening * is asynchronous anyway, so waiting costs nothing the user can see. + * Read failures reject rather than choosing a browser without the saved preference. */ export async function resolveBrowserLinkTargetPreference(): Promise { await ensureClientSettingsHydrated(); diff --git a/apps/web/src/browser/browserRecording.test.ts b/apps/web/src/browser/browserRecording.test.ts index 49145f314e98..5cfe614f2985 100644 --- a/apps/web/src/browser/browserRecording.test.ts +++ b/apps/web/src/browser/browserRecording.test.ts @@ -5,6 +5,8 @@ import { } from "@t3tools/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { ensureClientSettingsHydrated } from "~/hooks/useSettings"; + const { clientSettings, events, @@ -241,6 +243,35 @@ describe("browser recording", () => { await stopBrowserRecording("recording-tab"); }); + it("clears a failed settings read before retrying recording", async () => { + const tabId = "settings-read-failure-tab"; + const error = new Error("Settings read failed"); + vi.mocked(ensureClientSettingsHydrated).mockRejectedValueOnce(error); + + await expect(startBrowserRecording(tabId)).rejects.toBe(error); + + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + expect(animationFrameCount).toBe(0); + expect(startScreencast).not.toHaveBeenCalled(); + expect(stopScreencast).not.toHaveBeenCalled(); + expect(getDisplayMedia).not.toHaveBeenCalled(); + expect(FakeMediaRecorder.instances).toHaveLength(0); + + clientSettings.browserRecordingFrameRate = 60; + await startBrowserRecording(tabId); + + expect(getDisplayMedia).toHaveBeenCalledWith({ + audio: false, + video: { frameRate: { max: 60 } }, + }); + await stopBrowserRecording(tabId); + + expect(startScreencast).toHaveBeenCalledOnce(); + expect(readActiveBrowserRecordingTabIds()).toEqual(new Set()); + expect(useBrowserSurfaceStore.getState().activityByTabId[tabId]).toBeUndefined(); + }); + it("stops the native stream when MediaRecorder cleanup fails", async () => { const stopTrack = vi.fn(); getDisplayMedia.mockResolvedValueOnce({ diff --git a/apps/web/src/browser/browserRecording.ts b/apps/web/src/browser/browserRecording.ts index 73bc2708ddf6..c7825961abbc 100644 --- a/apps/web/src/browser/browserRecording.ts +++ b/apps/web/src/browser/browserRecording.ts @@ -516,10 +516,12 @@ export async function startBrowserRecording( activeRecordings.set(tabId, recording); publishActiveRecordingTabIds(); try { - const frameRatePromise = ensureClientSettingsHydrated().then( - () => getClientSettings().browserRecordingFrameRate, - ); - const [frameRate] = await Promise.all([frameRatePromise, waitForBrowserRecordingPaint()]); + await ensureClientSettingsHydrated().catch((cause: unknown) => { + clearActiveRecording(recording); + throw cause; + }); + const frameRate = getClientSettings().browserRecordingFrameRate; + await waitForBrowserRecordingPaint(); const throwIfStartupCancelled = async (): Promise => { // Once a grant starts, a stop lets startup finish so the caller receives an artifact. // Only a contended start can be cancelled before it reaches native capture. diff --git a/apps/web/src/browser/desktopTabLifetime.test.ts b/apps/web/src/browser/desktopTabLifetime.test.ts index 80bfa0d275d7..c5338ecf4ccf 100644 --- a/apps/web/src/browser/desktopTabLifetime.test.ts +++ b/apps/web/src/browser/desktopTabLifetime.test.ts @@ -1,6 +1,7 @@ import { DEFAULT_PREVIEW_APPEARANCE, DEFAULT_PREVIEW_ZOOM_FACTOR, + DEFAULT_CLIENT_SETTINGS, EnvironmentId, ThreadId, } from "@t3tools/contracts"; @@ -21,8 +22,10 @@ vi.mock("./browserRecording", () => ({ })); import { acquireDesktopTab } from "./desktopTabLifetime"; +import * as browserDefaults from "./browserDefaults"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; -/** Client settings are unset in tests, so creation carries the schema defaults. */ +/** Tests load default settings unless they select other preferences. */ const DEFAULT_TAB_STATE = { zoomFactor: DEFAULT_PREVIEW_ZOOM_FACTOR, colorScheme: DEFAULT_PREVIEW_APPEARANCE, @@ -31,6 +34,7 @@ import { previewRuntimeTabId } from "./previewRuntimeTabId"; describe("desktopTabLifetime", () => { beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); closeTab.mockClear(); createTab.mockClear(); stopBrowserRecording.mockClear(); @@ -40,6 +44,35 @@ describe("desktopTabLifetime", () => { afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("does not create a desktop tab after a failed settings read and permits a later retry", async () => { + vi.useFakeTimers(); + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const failed = acquireDesktopTab("tab_settings_retry"); + + await expect(failed.ready).rejects.toBe(failure); + expect(createTab).not.toHaveBeenCalled(); + failed.release(); + await vi.advanceTimersByTimeAsync(0); + + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultZoomFactor: 1.25, + browserDefaultAppearance: "dark", + }); + createTab.mockResolvedValueOnce(undefined); + const retry = acquireDesktopTab("tab_settings_retry"); + await retry.ready; + + expect(createTab).toHaveBeenCalledExactlyOnceWith("tab_settings_retry", { + zoomFactor: 1.25, + colorScheme: "dark", + }); + retry.release(); + await vi.advanceTimersByTimeAsync(0); }); it("shares tab creation readiness across concurrent leases", async () => { diff --git a/apps/web/src/browser/openFileInPreview.ts b/apps/web/src/browser/openFileInPreview.ts index f506e42e73e5..a320e3ba34da 100644 --- a/apps/web/src/browser/openFileInPreview.ts +++ b/apps/web/src/browser/openFileInPreview.ts @@ -38,6 +38,14 @@ export class BrowserPreviewUnavailableError extends Data.TaggedError( readonly message: string; }> {} +export class BrowserSettingsReadError extends Data.TaggedError("BrowserSettingsReadError")<{ + readonly cause: unknown; +}> { + override get message(): string { + return "Saved browser settings could not be loaded."; + } +} + export type OpenPreviewMutation = (input: { readonly environmentId: EnvironmentId; readonly input: PreviewOpenInput; @@ -47,8 +55,13 @@ export async function openUrlInPreview(input: { readonly threadRef: ScopedThreadRef; readonly url: string; readonly openPreview: OpenPreviewMutation; -}): Promise> { - const defaults = await resolveBrowserDefaults(); +}): Promise> { + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { @@ -82,7 +95,12 @@ export async function openFileInPreview(input: { readonly input: { readonly resource: AssetResource }; }) => Promise>; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise< + AtomCommandResult< + void, + AssetError | PreviewError | BrowserPreviewUnavailableError | BrowserSettingsReadError + > +> { if (!isPreviewSupportedInRuntime()) { return AsyncResult.failure( Cause.fail( diff --git a/apps/web/src/browser/useOpenLink.ts b/apps/web/src/browser/useOpenLink.ts index 0e9bf721f82d..2a1d122eedbf 100644 --- a/apps/web/src/browser/useOpenLink.ts +++ b/apps/web/src/browser/useOpenLink.ts @@ -1,5 +1,8 @@ import type { ScopedThreadRef } from "@t3tools/contracts"; -import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { useCallback } from "react"; import { recordVisitForThread } from "~/browserHistoryStore"; @@ -12,7 +15,7 @@ import { resolveBrowserLinkTargetPreference, resolveLinkTarget, } from "./browserLinkTarget"; -import { openUrlInPreview } from "./openFileInPreview"; +import { BrowserSettingsReadError, openUrlInPreview } from "./openFileInPreview"; const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; @@ -24,8 +27,8 @@ const NO_MODIFIER = { metaKey: false, ctrlKey: false } as const; * * An in-app open that fails falls back to the system browser rather than * dropping the click: the user asked for the link, and the setting only says - * where it should go first. The returned promise rejects only when that - * fallback fails too, the same way `shell.openExternal` does. + * where it should go first. Failed settings reads reject without opening a + * browser. The promise also rejects if the system-browser fallback fails. */ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( url: string, @@ -52,6 +55,8 @@ export function useOpenLink(threadRef: ScopedThreadRef | null | undefined): ( recordVisitForThread(targetThreadRef, url); return; } + const failure = squashAtomCommandFailure(result); + if (failure instanceof BrowserSettingsReadError) throw failure; console.error(result.cause); } const api = readLocalApi(); diff --git a/apps/web/src/clientPersistenceStorage.test.ts b/apps/web/src/clientPersistenceStorage.test.ts index db69fe96c80a..a86177b48eb3 100644 --- a/apps/web/src/clientPersistenceStorage.test.ts +++ b/apps/web/src/clientPersistenceStorage.test.ts @@ -52,22 +52,45 @@ describe("clientPersistenceStorage", () => { expect(readBrowserClientSettings()).toEqual(settings); }); - it("reports structured decode failures while preserving the fallback", async () => { + it.each(["not-json", '{"wordWrap":"invalid"}'])( + "does not treat invalid saved settings as absent: %s", + async (value) => { + const testWindow = getTestWindow(); + testWindow.localStorage.setItem("t3code:client-settings:v1", value); + const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); + + expect(() => readBrowserClientSettings()).toThrow( + expect.objectContaining({ + _tag: "LocalStorageOperationError", + operation: "decode", + storageKey: "t3code:client-settings:v1", + }), + ); + expect(testWindow.localStorage.getItem("t3code:client-settings:v1")).toBe(value); + }, + ); + + it("preserves saved settings across a transient read failure", async () => { const testWindow = getTestWindow(); - testWindow.localStorage.setItem("t3code:client-settings:v1", "not-json"); - const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + const settings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const }; + testWindow.localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); + const write = vi.spyOn(testWindow.localStorage, "setItem"); + const failure = new Error("storage unavailable"); + vi.spyOn(testWindow.localStorage, "getItem").mockImplementationOnce(() => { + throw failure; + }); const { readBrowserClientSettings } = await import("./clientPersistenceStorage"); - expect(readBrowserClientSettings()).toBeNull(); - expect(consoleError).toHaveBeenCalledWith( - "Could not read persisted client settings.", + expect(() => readBrowserClientSettings()).toThrow( expect.objectContaining({ _tag: "LocalStorageOperationError", - operation: "decode", + operation: "read", storageKey: "t3code:client-settings:v1", - cause: expect.anything(), + cause: failure, }), ); + expect(readBrowserClientSettings()).toEqual(settings); + expect(write).not.toHaveBeenCalled(); }); it("defaults word wrap on and discards obsolete wrapping preferences", async () => { diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index 5c0ba7c6eccf..f39ea63c5a7c 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -13,12 +13,7 @@ export function readBrowserClientSettings(): ClientSettings | null { return null; } - try { - return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); - } catch (error) { - console.error("Could not read persisted client settings.", error); - return null; - } + return getLocalStorageItem(CLIENT_SETTINGS_STORAGE_KEY, ClientSettingsSchema); } export function writeBrowserClientSettings(settings: ClientSettings): void { diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index af41324be6a7..55de831a52e7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -176,6 +176,7 @@ import { openFileInPreview, openUrlInPreview, BrowserPreviewUnavailableError, + BrowserSettingsReadError, } from "../browser/openFileInPreview"; import { resolveLinkTarget } from "../browser/browserLinkTarget"; import { PullRequestLinkPreview } from "./pullRequest/PullRequestLinkPreview"; @@ -2357,6 +2358,18 @@ function useChatMarkdownState({ } return openUrlInPreview({ threadRef, url, openPreview }).then((result) => { if (result._tag === "Success") recordVisitForThread(threadRef, url); + else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link in browser", + description: error.message, + }), + ); + } + } return result; }); }, @@ -2789,14 +2802,14 @@ const CHAT_MARKDOWN_COMPONENTS = { } event.preventDefault(); event.stopPropagation(); - // The click was taken from the shell, so an in-app open that fails - // hands the link to the system browser instead of dropping it. + // Keep the link here if saved settings could not be read. void openExternalLinkInPreview(href).then((result) => { if (result._tag === "Success" || isAtomCommandInterrupted(result)) return; reportMarkdownActionFailure( { operation: "open-link-in-preview", target: href }, result.cause, ); + if (squashAtomCommandFailure(result) instanceof BrowserSettingsReadError) return; void readLocalApi()?.shell.openExternal(href); }); }} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b27d66c7611d..11226371172f 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -162,6 +162,7 @@ import { useThreadPreviewState, } from "../previewStateStore"; import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "../browser/openFileInPreview"; import { addBrowserSurface } from "./preview/addBrowserSurface"; import { closePreviewSession } from "./preview/closePreviewSession"; import { ThreadPreviewMiniPlayer } from "./preview/ThreadPreviewMiniPlayer"; @@ -3718,6 +3719,18 @@ export default function ChatView(props: ChatViewProps) { threadRef: activeThreadRef, openPreview, ...(profileId === undefined ? {} : { profileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } }); }, [activeThreadRef, openPreview], diff --git a/apps/web/src/components/ThreadTerminalDrawer.test.ts b/apps/web/src/components/ThreadTerminalDrawer.test.ts index d9dcd6e79936..1624a739bb1a 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.test.ts +++ b/apps/web/src/components/ThreadTerminalDrawer.test.ts @@ -1,11 +1,98 @@ -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { shouldClearTerminalSelectionAction, shouldHandleTerminalExit, + terminalContextMenuItems, terminalSelectionLineRange, + terminalSelectionMenuItems, + terminalThemeFromApp, } from "./ThreadTerminalDrawer"; +describe("terminal selection menus", () => { + it("omits Add to chat when the terminal has no chat target", () => { + expect(terminalSelectionMenuItems().map(({ id }) => id)).toEqual(["add-to-chat", "copy"]); + expect(terminalContextMenuItems({ hasSelection: true }).map(({ id }) => id)).toEqual([ + "add-to-chat", + "copy", + "paste", + ]); + + expect(terminalSelectionMenuItems({ canAddToChat: false }).map(({ id }) => id)).toEqual([ + "copy", + ]); + expect( + terminalContextMenuItems({ hasSelection: true, canAddToChat: false }).map(({ id }) => id), + ).toEqual(["copy", "paste"]); + }); +}); + +describe("terminalThemeFromApp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("uses terminal colors inherited by the mount instead of a light document theme", () => { + const root = { classList: { contains: () => false } }; + const body = {}; + const drawer = {}; + let canvasColor = "#000"; + const colors: Record = { + "#000": [0, 0, 0, 255], + "#fff": [255, 255, 255, 255], + "#ddd": [221, 221, 221, 255], + "#111": [17, 17, 17, 255], + }; + + vi.stubGlobal("document", { + documentElement: root, + body, + querySelector: () => drawer, + createElement: () => ({ + width: 0, + height: 0, + getContext: () => ({ + clearRect: () => undefined, + fillRect: () => undefined, + get fillStyle() { + return canvasColor; + }, + set fillStyle(value: string) { + canvasColor = value; + }, + getImageData: () => ({ data: colors[canvasColor] ?? [0, 0, 0, 0] }), + }), + }), + }); + vi.stubGlobal("getComputedStyle", (element: object) => { + const local = element === drawer; + const values = local + ? { + "--terminal-background": "#000", + "--terminal-foreground": "#fff", + "--terminal-cursor": "#ddd", + "--terminal-selection-background": "rgba(255, 255, 255, 0.2)", + } + : { + "--terminal-background": "#fff", + "--terminal-foreground": "#111", + }; + return { + backgroundColor: local ? "#000" : "#fff", + color: local ? "#fff" : "#111", + colorScheme: local ? "dark" : "light", + getPropertyValue: (name: string) => values[name as keyof typeof values] ?? "", + }; + }); + + const theme = terminalThemeFromApp(); + + expect(theme.background).toEqual({ r: 0, g: 0, b: 0 }); + expect(theme.foreground).toEqual({ r: 255, g: 255, b: 255 }); + expect(theme.cursor).toEqual({ r: 221, g: 221, b: 221 }); + }); +}); + describe("terminal selection actions", () => { it("clears a pending or currently owned menu when the selection disappears", () => { expect( diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 9f4956aae682..d9ddf9225bdf 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -20,6 +20,7 @@ import { } from "lucide-react"; import { type ContextMenuItem, + type ProviderInstanceId, type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, @@ -41,6 +42,7 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; import { type TerminalContextSelection } from "~/lib/terminalContext"; @@ -172,16 +174,23 @@ function terminalFontOptions(family: string, size: number): { family?: string; s } export function terminalThemeFromApp(mountElement?: HTMLElement | null): GhosttyTheme { - const isDark = document.documentElement.classList.contains("dark"); - const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; - const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const drawerSurface = mountElement?.closest(".thread-terminal-drawer") ?? document.querySelector(".thread-terminal-drawer") ?? document.body; const drawerStyles = getComputedStyle(drawerSurface); + const themeStyles = mountElement ? getComputedStyle(mountElement) : drawerStyles; + const colorScheme = themeStyles.colorScheme; + const isDark = + colorScheme === "dark" + ? true + : colorScheme === "light" + ? false + : document.documentElement.classList.contains("dark"); + const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; + const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; const bodyStyles = getComputedStyle(document.body); - const themeStyles = getComputedStyle(document.documentElement); + const rootThemeStyles = getComputedStyle(document.documentElement); const background = normalizeComputedColor( drawerStyles.backgroundColor, normalizeComputedColor(bodyStyles.backgroundColor, fallbackBackground), @@ -190,8 +199,16 @@ export function terminalThemeFromApp(mountElement?: HTMLElement | null): Ghostty drawerStyles.color, normalizeComputedColor(bodyStyles.color, fallbackForeground), ); - const terminalBackground = readThemeColor(themeStyles, "--terminal-background", background); - const terminalForeground = readThemeColor(themeStyles, "--terminal-foreground", foreground); + const terminalBackground = readThemeColor( + themeStyles, + "--terminal-background", + readThemeColor(rootThemeStyles, "--terminal-background", background), + ); + const terminalForeground = readThemeColor( + themeStyles, + "--terminal-foreground", + readThemeColor(rootThemeStyles, "--terminal-foreground", foreground), + ); const terminalCursor = readThemeColor( themeStyles, "--terminal-cursor", @@ -232,10 +249,14 @@ export function terminalSelectionLineRange(position: { export type TerminalContextMenuAction = "add-to-chat" | "copy" | "paste"; -/** Post-selection popup: just the two selection actions, always enabled. */ -export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "copy">[] { +/** Post-selection popup: available selection actions, always enabled. */ +export function terminalSelectionMenuItems(options?: { + canAddToChat?: boolean; +}): ContextMenuItem<"add-to-chat" | "copy">[] { return [ - { id: "add-to-chat", label: "Add to chat" }, + ...(options?.canAddToChat === false + ? [] + : ([{ id: "add-to-chat", label: "Add to chat" }] satisfies ContextMenuItem<"add-to-chat">[])), { id: "copy", label: "Copy" }, ]; } @@ -248,11 +269,13 @@ export function terminalSelectionMenuItems(): ContextMenuItem<"add-to-chat" | "c */ export function terminalContextMenuItems(options: { hasSelection: boolean; + canAddToChat?: boolean; }): ContextMenuItem[] { + const { hasSelection, canAddToChat = true } = options; return [ - ...terminalSelectionMenuItems().map((item) => ({ + ...terminalSelectionMenuItems({ canAddToChat }).map((item) => ({ ...item, - disabled: !options.hasSelection, + disabled: !hasSelection, })), { id: "paste", label: "Paste" }, ]; @@ -292,8 +315,9 @@ interface TerminalViewportProps { cwd: string; worktreePath?: string | null; runtimeEnv?: Record; + providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: (selection: TerminalContextSelection) => void; focusRequestId: number; autoFocus: boolean; visible: boolean; @@ -317,6 +341,7 @@ export function TerminalViewport({ cwd, worktreePath, runtimeEnv, + providerInstanceId, onSessionExited, onAddTerminalContext, focusRequestId, @@ -357,8 +382,9 @@ export function TerminalViewport({ onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext(selection); + onAddTerminalContext?.(selection); }); + const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); const terminalFontFamily = useClientSettings((settings) => resolveTerminalFontPreference({ @@ -383,6 +409,7 @@ export function TerminalViewport({ cwd, ...(worktreePath !== undefined ? { worktreePath } : {}), ...(runtimeEnv ? { env: runtimeEnv } : {}), + ...(providerInstanceId ? { providerInstanceId } : {}), }, }); const writeTerminal = useEffectEvent((data: string) => @@ -635,7 +662,10 @@ export function TerminalViewport({ let clicked: TerminalContextMenuAction | null; try { clicked = await localApi.contextMenu.show( - terminalContextMenuItems({ hasSelection: selectionAction !== null }), + terminalContextMenuItems({ + hasSelection: selectionAction !== null, + canAddToChat: canAddSelectionToChat(), + }), { x: event.clientX, y: event.clientY }, ); } catch (error) { @@ -648,7 +678,9 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - if (selectionAction) addSelectionToChat(selectionAction.selection); + if (selectionAction && canAddSelectionToChat()) { + addSelectionToChat(selectionAction.selection); + } return; case "copy": if (selectionAction) await copySelection(selectionAction.clipboardText, requestId); @@ -675,7 +707,10 @@ export function TerminalViewport({ const requestId = ++selectionActionRequestIdRef.current; openSelectionMenuRequestIdRef.current = requestId; const clicked = await localApi.contextMenu - .show(terminalSelectionMenuItems(), nextAction.position) + .show( + terminalSelectionMenuItems({ canAddToChat: canAddSelectionToChat() }), + nextAction.position, + ) .finally(() => { if (openSelectionMenuRequestIdRef.current === requestId) { openSelectionMenuRequestIdRef.current = null; @@ -686,7 +721,7 @@ export function TerminalViewport({ } switch (clicked) { case "add-to-chat": - addSelectionToChat(nextAction.selection); + if (canAddSelectionToChat()) addSelectionToChat(nextAction.selection); return; case "copy": await copySelection(nextAction.clipboardText, requestId); @@ -768,6 +803,14 @@ export function TerminalViewport({ threadRef, openPreview, fallbackToBrowser, + }).catch((error: unknown) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open link", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); }); return; } diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx new file mode 100644 index 000000000000..e82135dc44bd --- /dev/null +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.test.tsx @@ -0,0 +1,214 @@ +import type { Discovery } from "@t3tools/client-runtime/relay"; +import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; +import { act, type ButtonHTMLAttributes } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +type DiscoveredEnvironments = Discovery.RelayEnvironmentDiscoveryState["environments"]; + +const discovery = vi.hoisted(() => ({ + state: null as Discovery.RelayEnvironmentDiscoveryState | null, + listeners: new Set<() => void>(), + refreshCommand: Symbol("refresh"), + registerCommand: Symbol("register"), + refresh: vi.fn<() => Promise>>(), + register: vi.fn(), + listEnvironments: vi.fn<() => Promise>(), +})); + +vi.mock("~/state/relay", () => ({ + relayEnvironmentDiscovery: { refresh: discovery.refreshCommand }, +})); +vi.mock("~/connection/catalog", () => ({ + environmentCatalog: { register: discovery.registerCommand }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => + command === discovery.refreshCommand ? discovery.refresh : discovery.register, +})); +vi.mock("~/state/environments", async () => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + discovery.listeners.add(listener); + return () => discovery.listeners.delete(listener); + }; + const read = () => { + if (discovery.state === null) throw new Error("Discovery fixture is not initialized"); + return discovery.state; + }; + return { useRelayEnvironmentDiscovery: () => useSyncExternalStore(subscribe, read, read) }; +}); +vi.mock("../ConnectionStatusDot", () => ({ ConnectionStatusDot: () => null })); +vi.mock("../ui/button", () => ({ + Button: ({ children, ...props }: ButtonHTMLAttributes) => ( + + ), +})); +vi.mock("../ui/toast", () => ({ toastManager: { add: vi.fn() } })); + +import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; + +const newMachineId = EnvironmentId.make("new-computer"); +const linkedMachines: DiscoveredEnvironments = new Map([ + [ + newMachineId, + { + environment: { + environmentId: newMachineId, + label: "Work laptop", + endpoint: { + httpBaseUrl: "https://relay.example.test", + wsBaseUrl: "wss://relay.example.test/ws", + providerKind: "manual", + }, + linkedAt: "2026-09-05T12:00:00.000Z", + }, + availability: "online", + status: Option.none(), + error: Option.none(), + }, + ], +]); + +let renderer: ReactTestRenderer | null; +let page: EventTarget & { visibilityState: DocumentVisibilityState }; +let browserWindow: EventTarget; + +function publish(state: Discovery.RelayEnvironmentDiscoveryState) { + discovery.state = state; + for (const listener of discovery.listeners) listener(); +} + +async function mount(refreshWhileEmpty = true) { + await act(async () => { + renderer = create( + Waiting for your computer to connect.

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

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

+

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

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

Where is your code?

+

Choose where your agents will run.

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

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

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

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

+
+ + Waiting for your computer to connect. +

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

+ 01 Run this on your server +

+ +

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

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

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

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

+ {SCAN_LIMIT_MESSAGE} +

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

You can add projects later.

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

{importError}

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

+ {recent.length - 4} more projects +

+ ) : null} +
+ {importError ?

{importError}

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

{title}

+ {description ? ( +

{description}

+ ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const { copyToClipboard, isCopied } = useCopyToClipboard({ + timeout: 1500, + target: "command", + }); + return ( +
+ + $ + {command} + + +
+ ); +} + +function formatSource(source: "claudeAgent" | "codex"): string { + return source === "claudeAgent" ? "Claude" : "Codex"; +} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx new file mode 100644 index 000000000000..75ed20cc4fe7 --- /dev/null +++ b/apps/web/src/components/preview/PreviewAutomationHosts.test.tsx @@ -0,0 +1,190 @@ +import { + DEFAULT_CLIENT_SETTINGS, + EnvironmentId, + ThreadId, + type ClientSettings, + type PreviewAutomationResponse, + type PreviewAutomationStreamEvent, + type PreviewOpenInput, + type PreviewSessionSnapshot, +} from "@t3tools/contracts"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { __resetClientSettingsPersistenceForTests } from "~/hooks/useSettings"; +import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; +import { appAtomRegistry, AppAtomRegistryProvider } from "~/rpc/atomRegistry"; + +import { PreviewAutomationHosts } from "./PreviewAutomationHosts"; + +const mocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn(), + open: vi.fn(async (_target: { environmentId: EnvironmentId; input: PreviewOpenInput }) => + AsyncResult.success(snapshot), + ), + list: vi.fn(async () => AsyncResult.success(emptyList)), + resize: vi.fn(), + respond: + vi.fn< + (target: { environmentId: EnvironmentId; input: PreviewAutomationResponse }) => Promise + >(), + focus: vi.fn(async () => undefined), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: mocks }), +})); +vi.mock("~/env", () => ({ isElectron: true })); +vi.mock("~/state/environments", () => ({ + useEnvironments: () => ({ environments: [{ environmentId }] }), +})); +vi.mock("~/state/preview", () => ({ + previewEnvironment: { + automationRequests: () => requestsAtom, + list: () => listAtom, + open: mocks.open, + resize: mocks.resize, + respondToAutomation: mocks.respond, + focusAutomationHost: mocks.focus, + }, +})); +vi.mock("~/state/use-atom-command", () => ({ + useAtomCommand: (command: unknown) => command, +})); +vi.mock("~/state/use-atom-query-runner", () => ({ + useAtomQueryRunner: () => mocks.list, +})); +vi.mock("./previewBridge", () => ({ previewBridge: { automation: {} } })); + +const environmentId = EnvironmentId.make("automation-environment"); +const threadId = ThreadId.make("automation-thread"); +const threadRef = { environmentId, threadId }; +const viewport = { _tag: "freeform", width: 1440, height: 900 } as const; +const savedSettings: ClientSettings = { + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], +}; +const snapshot: PreviewSessionSnapshot = { + threadId, + tabId: "automation-tab", + navStatus: { _tag: "Idle" }, + canGoBack: false, + canGoForward: false, + viewport, + profileId: "work", + updatedAt: "2026-09-05T00:00:00.000Z", +}; +const emptyList = { sessions: [], serverEpoch: "test-server", revision: 0 }; +const listAtom = Atom.make(AsyncResult.success(emptyList)); +const requestsAtom = Atom.make>( + AsyncResult.initial(false), +); +const requestEvent: PreviewAutomationStreamEvent = { + type: "request", + connectionId: "automation-connection", + request: { + requestId: "open-request", + threadId, + operation: "open", + input: { open: false, reuseExistingTab: false }, + timeoutMs: 15_000, + }, +}; + +function deferred
() { + let resolve!: (value: A) => void; + const promise = new Promise((complete) => { + resolve = complete; + }); + return { promise, resolve }; +} + +let renderer: ReactTestRenderer | null = null; + +beforeEach(async () => { + vi.clearAllMocks(); + mocks.getClientSettings.mockReset().mockResolvedValue(savedSettings); + mocks.respond.mockReset(); + __resetClientSettingsPersistenceForTests(); + resetPreviewStateForTests(); + appAtomRegistry.set(requestsAtom, AsyncResult.initial(false)); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal("window", { addEventListener: vi.fn(), removeEventListener: vi.fn() }); + vi.stubGlobal("document", { hasFocus: () => false, querySelectorAll: () => [] }); + await act(() => { + renderer = create( + + + , + ); + }); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = null; + resetPreviewStateForTests(); + __resetClientSettingsPersistenceForTests(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("PreviewAutomationHosts open", () => { + it("waits for saved settings before opening a tab with the configured profile and viewport", async () => { + const readStarted = deferred(); + const read = deferred(); + const response = deferred(); + mocks.getClientSettings.mockImplementationOnce(() => { + readStarted.resolve(); + return read.promise; + }); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await readStarted.promise; + }); + expect(mocks.open).not.toHaveBeenCalled(); + + await act(async () => { + read.resolve(savedSettings); + await response.promise; + }); + + expect(mocks.open).toHaveBeenCalledExactlyOnceWith({ + environmentId, + input: { threadId, viewport, profileId: "work" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + await expect(response.promise).resolves.toMatchObject({ requestId: "open-request", ok: true }); + expect(readThreadPreviewState(threadRef).snapshot).toEqual(snapshot); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); + + it("reports a settings read failure without opening a tab", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + mocks.getClientSettings.mockRejectedValueOnce(new Error("Settings read failed")); + const response = deferred(); + mocks.respond.mockImplementationOnce(async ({ input }) => response.resolve(input)); + + await act(async () => { + appAtomRegistry.set(requestsAtom, AsyncResult.success(requestEvent)); + await response.promise; + }); + + await expect(response.promise).resolves.toMatchObject({ + requestId: "open-request", + ok: false, + error: { _tag: "PreviewAutomationExecutionError" }, + }); + expect(mocks.getClientSettings).toHaveBeenCalledOnce(); + expect(mocks.open).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(mocks.setClientSettings).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index 08b31640906e..fd87f7e80c79 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -41,7 +41,11 @@ import { acquireBrowserSurfaceActivity, useBrowserSurfaceStore, } from "~/browser/browserSurfaceStore"; -import { browserDefaultOpenViewport, resolveBrowserDefaults } from "~/browser/browserDefaults"; +import { + browserDefaultOpenProfileId, + browserDefaultOpenViewport, + resolveBrowserDefaults, +} from "~/browser/browserDefaults"; import { runBrowserViewportMutation } from "~/browser/browserViewportActions"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; import { isElectron } from "~/env"; @@ -412,6 +416,7 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) const reusedExistingTab = activeTabId !== null; tabId = activeTabId; if (!activeTabId) { + const defaults = await resolveBrowserDefaults(); const result = await open({ environmentId, input: { @@ -419,7 +424,8 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) ...(resolvedInputUrl ? { url: resolvedInputUrl } : {}), // An agent that didn't state a size gets the user's // configured default, same as a hand-opened tab. - viewport: browserDefaultOpenViewport(await resolveBrowserDefaults()), + viewport: browserDefaultOpenViewport(defaults), + profileId: browserDefaultOpenProfileId(defaults), }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 7e0bf2dfb543..e6ad2758bc48 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -1,7 +1,10 @@ "use client"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; import { DEFAULT_BROWSER_PROFILE_ID, FILL_PREVIEW_VIEWPORT, @@ -46,6 +49,7 @@ import { } from "~/browser/browserViewportActions"; import { browserResponsiveViewportForToggle, useBrowserDefaults } from "~/browser/browserDefaults"; import { previewRuntimeTabId } from "~/browser/previewRuntimeTabId"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { PreviewUnreachable } from "./PreviewUnreachable"; import { revealInFileExplorerLabel } from "./fileExplorerLabel"; import { shouldShowPreviewEmptyState } from "./previewEmptyStateLogic"; @@ -186,6 +190,16 @@ export function PreviewView({ return true; } const result = await openPreviewSession({ openPreview: open, threadRef, url: resolvedUrl }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add({ + type: "error", + title: "Unable to open browser", + description: error.message, + }); + } + } return result._tag === "Success"; }, [open, runtimeTabId, threadRef], diff --git a/apps/web/src/components/preview/addBrowserSurface.test.ts b/apps/web/src/components/preview/addBrowserSurface.test.ts index f26cb0fff9e1..d34de83a23b4 100644 --- a/apps/web/src/components/preview/addBrowserSurface.test.ts +++ b/apps/web/src/components/preview/addBrowserSurface.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -14,6 +15,7 @@ import { resetPreviewStateForTests, } from "~/previewStateStore"; import { selectThreadRightPanelState, useRightPanelStore } from "~/rightPanelStore"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { addBrowserSurface } from "./addBrowserSurface"; @@ -32,6 +34,7 @@ const snapshot = (tabId: string): PreviewSessionSnapshot => ({ }); beforeEach(() => { + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); resetPreviewStateForTests(); useRightPanelStore.setState({ byThreadKey: {} }); }); diff --git a/apps/web/src/components/preview/addBrowserSurface.ts b/apps/web/src/components/preview/addBrowserSurface.ts index 622cdbec2f1c..e0cd83501201 100644 --- a/apps/web/src/components/preview/addBrowserSurface.ts +++ b/apps/web/src/components/preview/addBrowserSurface.ts @@ -4,7 +4,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef } from "@t3tools/contracts"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -15,7 +15,7 @@ export async function addBrowserSurface(input: { readonly openPreview: OpenPreviewMutation; /** Omit to use the configured default profile. */ readonly profileId?: string | undefined; -}): Promise> { +}): Promise> { const result = await openPreviewSession({ openPreview: input.openPreview, threadRef: input.threadRef, diff --git a/apps/web/src/components/preview/openDiscoveredPort.ts b/apps/web/src/components/preview/openDiscoveredPort.ts index a49acbd86104..288db101e7a5 100644 --- a/apps/web/src/components/preview/openDiscoveredPort.ts +++ b/apps/web/src/components/preview/openDiscoveredPort.ts @@ -5,7 +5,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; -import type { OpenPreviewMutation } from "~/browser/openFileInPreview"; +import type { BrowserSettingsReadError, OpenPreviewMutation } from "~/browser/openFileInPreview"; import { recordVisitForThread } from "~/browserHistoryStore"; import { useRightPanelStore } from "~/rightPanelStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -14,7 +14,7 @@ export async function openDiscoveredPort(input: { readonly threadRef: ScopedThreadRef; readonly port: DiscoveredLocalServer; readonly openPreview: OpenPreviewMutation; -}): Promise> { +}): Promise> { const resolvedUrl = resolveDiscoveredServerUrl(input.threadRef.environmentId, input.port.url); const result = await openPreviewSession({ openPreview: input.openPreview, diff --git a/apps/web/src/components/preview/openPreviewSession.test.ts b/apps/web/src/components/preview/openPreviewSession.test.ts index ef3d51a9e7fa..fe14211280c2 100644 --- a/apps/web/src/components/preview/openPreviewSession.test.ts +++ b/apps/web/src/components/preview/openPreviewSession.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_BROWSER_PROFILE_ID, + DEFAULT_CLIENT_SETTINGS, FILL_PREVIEW_VIEWPORT, type PreviewOpenInput, type PreviewSessionSnapshot, @@ -7,8 +8,11 @@ import { } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import * as browserDefaults from "~/browser/browserDefaults"; +import { BrowserSettingsReadError, openUrlInPreview } from "~/browser/openFileInPreview"; +import { __setClientSettingsForTests } from "~/hooks/useSettings"; import { readThreadPreviewState, resetPreviewStateForTests } from "~/previewStateStore"; import { openPreviewSession } from "./openPreviewSession"; @@ -31,7 +35,14 @@ const snapshot: PreviewSessionSnapshot = { updatedAt: "2026-06-11T23:00:00.000Z", }; -beforeEach(resetPreviewStateForTests); +beforeEach(() => { + resetPreviewStateForTests(); + __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); describe("openPreviewSession", () => { it("creates an idle tab without recording a recently visited URL", async () => { @@ -88,4 +99,44 @@ describe("openPreviewSession", () => { expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); }); + + it.each(["session", "link"] as const)( + "does not open a %s with unread settings and uses the saved profile on retry", + async (entryPoint) => { + const failure = new Error("Settings read failed"); + vi.spyOn(browserDefaults, "resolveBrowserDefaults").mockRejectedValueOnce(failure); + const viewport = { _tag: "freeform", width: 1280, height: 720 } as const; + __setClientSettingsForTests({ + ...DEFAULT_CLIENT_SETTINGS, + browserDefaultViewport: viewport, + browserDefaultProfileId: "work", + browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }], + }); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + const input = { openPreview, threadRef, url: "https://t3.chat/" }; + const open = entryPoint === "session" ? openPreviewSession : openUrlInPreview; + + const result = await open(input); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.squash(result.cause)).toBeInstanceOf(BrowserSettingsReadError); + expect(Cause.squash(result.cause)).toMatchObject({ cause: failure }); + } + expect(openPreview).not.toHaveBeenCalled(); + expect(readThreadPreviewState(threadRef).snapshot).toBeNull(); + expect(readThreadPreviewState(threadRef).recentlySeenUrls).toEqual([]); + + await expect(open(input)).resolves.toMatchObject({ _tag: "Success" }); + expect(openPreview).toHaveBeenCalledExactlyOnceWith({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + url: input.url, + viewport, + profileId: "work", + }, + }); + }, + ); }); diff --git a/apps/web/src/components/preview/openPreviewSession.ts b/apps/web/src/components/preview/openPreviewSession.ts index deb5465ebc28..07dab9a0b36d 100644 --- a/apps/web/src/components/preview/openPreviewSession.ts +++ b/apps/web/src/components/preview/openPreviewSession.ts @@ -6,12 +6,15 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { browserDefaultOpenProfileId, browserDefaultOpenViewport, resolveBrowserDefaults, } from "~/browser/browserDefaults"; +import { BrowserSettingsReadError } from "~/browser/openFileInPreview"; import { applyPreviewServerSnapshot, rememberPreviewUrl } from "~/previewStateStore"; interface OpenPreviewSessionInput { @@ -29,10 +32,15 @@ interface OpenPreviewSessionInput { export async function openPreviewSession( input: OpenPreviewSessionInput, -): Promise> { +): Promise> { // Resolved once: a tab opened before client settings hydrate would otherwise // be born at the schema defaults and never corrected. - const defaults = await resolveBrowserDefaults(); + const defaults = await resolveBrowserDefaults().catch( + (cause: unknown) => new BrowserSettingsReadError({ cause }), + ); + if (defaults instanceof BrowserSettingsReadError) { + return AsyncResult.failure(Cause.fail(defaults)); + } const result = await input.openPreview({ environmentId: input.threadRef.environmentId, input: { diff --git a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts index 46dd33f7beb4..2ce81cb06af2 100644 --- a/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts +++ b/apps/web/src/components/preview/openTerminalLinkInPreview.test.ts @@ -68,6 +68,33 @@ afterEach(() => { }); describe("openTerminalLinkInPreview", () => { + it.each(["target", "defaults"] as const)( + "does not open either browser when reading %s fails", + async (setting) => { + const failure = new Error("Settings read failed"); + if (setting === "target") { + linkTargetMocks.preference.mockImplementationOnce(() => { + throw failure; + }); + } else { + browserDefaultsMocks.resolve.mockRejectedValueOnce(failure); + } + const fallbackToBrowser = vi.fn(); + const openPreview = vi.fn(async () => AsyncResult.success(snapshot)); + + await expect( + openTerminalLinkInPreview({ + url: "https://example.com/docs", + threadRef, + openPreview, + fallbackToBrowser, + }), + ).rejects.toBe(failure); + expect(fallbackToBrowser).not.toHaveBeenCalled(); + expect(openPreview).not.toHaveBeenCalled(); + }, + ); + it("opens in the system browser while that is the configured target", async () => { linkTargetMocks.preference.mockReturnValue("system"); const fallbackToBrowser = vi.fn(); diff --git a/apps/web/src/components/settings/providerStatus.test.ts b/apps/web/src/components/settings/providerStatus.test.ts new file mode 100644 index 000000000000..46dc7e262512 --- /dev/null +++ b/apps/web/src/components/settings/providerStatus.test.ts @@ -0,0 +1,71 @@ +import { ProviderDriverKind, ProviderInstanceId, type ServerProvider } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { getProviderSummary } from "./providerStatus"; + +const provider: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated", label: "ChatGPT" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getProviderSummary", () => { + it("reports ready providers with unknown authentication as available", () => { + expect(getProviderSummary({ ...provider, auth: { status: "unknown" } })).toEqual({ + headline: "Available", + detail: null, + }); + }); + + it("does not hide a provider error behind a previous authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + message: "The provider process failed to start.", + }), + ).toEqual({ + headline: "Unavailable", + detail: "The provider process failed to start.", + }); + }); + + it("does not hide a provider warning behind an authenticated state", () => { + expect( + getProviderSummary({ + ...provider, + status: "warning", + message: "The provider version is unsupported.", + }), + ).toEqual({ + headline: "Needs attention", + detail: "The provider version is unsupported.", + }); + }); + + it("keeps authentication failures actionable when their provider status is error", () => { + expect( + getProviderSummary({ + ...provider, + status: "error", + auth: { status: "unauthenticated" }, + message: "Run codex login.", + }), + ).toEqual({ + headline: "Not authenticated", + detail: "Run codex login.", + }); + }); + + it("treats a disabled provider status as disabled even before its enabled flag updates", () => { + expect(getProviderSummary({ ...provider, status: "disabled" }).headline).toBe("Disabled"); + }); +}); diff --git a/apps/web/src/components/settings/providerStatus.ts b/apps/web/src/components/settings/providerStatus.ts index 0f39f643f5ce..90c618f5daa7 100644 --- a/apps/web/src/components/settings/providerStatus.ts +++ b/apps/web/src/components/settings/providerStatus.ts @@ -26,7 +26,8 @@ export type ProviderStatusKey = keyof typeof PROVIDER_STATUS_STYLES; * settings page. Prefers `provider.message` for server-supplied detail and * falls back to generic phrasing when the server has not yet reported any * state — which happens before the first probe or when an instance names a - * driver this build does not ship. + * driver this build does not ship. A ready provider without account metadata + * remains available and does not imply an authentication failure. */ export function getProviderSummary(provider: ServerProvider | undefined) { if (!provider) { @@ -35,7 +36,7 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: "Waiting for the server to report installation and authentication details.", }; } - if (!provider.enabled) { + if (!provider.enabled || provider.status === "disabled") { return { headline: "Disabled", detail: @@ -48,13 +49,6 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "CLI not detected on PATH.", }; } - if (provider.auth.status === "authenticated") { - const authLabel = provider.auth.label ?? provider.auth.type; - return { - headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", - detail: provider.message ?? null, - }; - } if (provider.auth.status === "unauthenticated") { return { headline: "Not authenticated", @@ -74,9 +68,16 @@ export function getProviderSummary(provider: ServerProvider | undefined) { detail: provider.message ?? "The provider failed its startup checks.", }; } + if (provider.auth.status === "authenticated") { + const authLabel = provider.auth.label ?? provider.auth.type; + return { + headline: authLabel ? `Authenticated · ${authLabel}` : "Authenticated", + detail: provider.message ?? null, + }; + } return { headline: "Available", - detail: provider.message ?? "Installed and ready, but authentication could not be verified.", + detail: provider.message ?? null, }; } diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index fe0345e41524..d76002a93298 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -353,6 +353,8 @@ export async function submitServerAuthCredential(credential: string): Promise { } }); - it("preserves decode failure context", async () => { + it("retries when access to browser storage becomes available", async () => { + const storage = createStorage(); + storage.setItem("read-key", JSON.stringify("saved value")); + let blocked = true; + vi.stubGlobal("window", { + get localStorage() { + if (blocked) throw new Error("storage unavailable"); + return storage; + }, + }); + const { getLocalStorageItem, LocalStorageOperationError } = await import("./useLocalStorage"); + + expect(() => getLocalStorageItem("read-key", Schema.String)).toThrow( + LocalStorageOperationError, + ); + blocked = false; + expect(getLocalStorageItem("read-key", Schema.String)).toBe("saved value"); + }); + + it.each(["", "not-json"])("preserves decode failure context for %j", async (value) => { const { getLocalStorageItem, LocalStorageOperationError } = await loadWithStorage( - createStorage({ getItem: () => "not-json" }), + createStorage({ getItem: () => value }), ); try { diff --git a/apps/web/src/hooks/useLocalStorage.ts b/apps/web/src/hooks/useLocalStorage.ts index 3099e73ff43f..112715599484 100644 --- a/apps/web/src/hooks/useLocalStorage.ts +++ b/apps/web/src/hooks/useLocalStorage.ts @@ -15,26 +15,26 @@ export class LocalStorageOperationError extends Schema.TaggedErrorClass(); - return { - clear: () => store.clear(), - getItem: (_) => store.get(_) ?? null, - key: (_) => Record.keys(store).at(_) ?? null, - get length() { - return store.size; - }, - removeItem: (_) => store.delete(_), - setItem: (_, value) => store.set(_, value), - }; - })(); +const fallbackStorage: Storage = (() => { + const store = new Map(); + return { + clear: () => store.clear(), + getItem: (_) => store.get(_) ?? null, + key: (_) => Record.keys(store).at(_) ?? null, + get length() { + return store.size; + }, + removeItem: (_) => store.delete(_), + setItem: (_, value) => store.set(_, value), + }; +})(); + +const getStorage = (): Storage => + typeof window !== "undefined" ? window.localStorage : fallbackStorage; const read = (key: string) => { try { - return isomorphicLocalStorage.getItem(key); + return getStorage().getItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "read", storageKey: key, cause }); } @@ -58,13 +58,13 @@ const encode = (key: string, schema: Schema.Codec, value: T) => { export const getLocalStorageItem = (key: string, schema: Schema.Codec): T | null => { const item = read(key); - return item ? decode(key, schema, item) : null; + return item === null ? null : decode(key, schema, item); }; export const setLocalStorageItem = (key: string, value: T, schema: Schema.Codec) => { const valueToSet = encode(key, schema, value); try { - isomorphicLocalStorage.setItem(key, valueToSet); + getStorage().setItem(key, valueToSet); } catch (cause) { throw new LocalStorageOperationError({ operation: "write", storageKey: key, cause }); } @@ -72,7 +72,7 @@ export const setLocalStorageItem = (key: string, value: T, schema: Schema. export const removeLocalStorageItem = (key: string) => { try { - isomorphicLocalStorage.removeItem(key); + getStorage().removeItem(key); } catch (cause) { throw new LocalStorageOperationError({ operation: "remove", storageKey: key, cause }); } diff --git a/apps/web/src/hooks/useSettings.test.ts b/apps/web/src/hooks/useSettings.test.ts index 200e14241e13..d55424766883 100644 --- a/apps/web/src/hooks/useSettings.test.ts +++ b/apps/web/src/hooks/useSettings.test.ts @@ -3,12 +3,22 @@ import { ProviderDriverKind, ProviderInstanceId, } from "@t3tools/contracts"; -import { DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts/settings"; -import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const persistenceMocks = vi.hoisted(() => ({ + getClientSettings: vi.fn<() => Promise>(), + setClientSettings: vi.fn<(settings: ClientSettings) => Promise>(), +})); + +vi.mock("~/localApi", () => ({ + ensureLocalApi: () => ({ persistence: persistenceMocks }), +})); import { __resetClientSettingsPersistenceForTests, __setClientSettingsForTests, + ensureClientSettingsHydrated, getClientSettings, mergeEnvironmentSettings, persistClientSettingsPatch, @@ -17,9 +27,138 @@ import { } from "./useSettings"; beforeEach(() => { + persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null); + persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined); __resetClientSettingsPersistenceForTests(); }); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe("client settings hydration", () => { + const savedSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }], + }; + const onboardingCompletedAt = "2026-09-05T12:00:00.000Z"; + const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt }); + + it("rejects completion after a failed read and preserves saved preferences on retry", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings + .mockRejectedValueOnce(failure) + .mockResolvedValue(savedSettings); + + await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + + const completedSettings = { ...savedSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2); + }); + + it("uses defaults only after storage confirms no saved settings exist", async () => { + const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt }; + + await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings); + }); + + it("holds patches until a pending read supplies the saved preferences", async () => { + let finishRead!: (settings: ClientSettings) => void; + persistenceMocks.getClientSettings.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRead = resolve; + }), + ); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + finishRead(savedSettings); + await hydration; + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce(); + }); + + it("handles failed patch reads without writing and retries with the saved preferences", async () => { + const failure = new Error("storage unavailable"); + vi.spyOn(console, "error").mockImplementation(() => undefined); + persistenceMocks.getClientSettings.mockRejectedValue(failure); + + const hydration = ensureClientSettingsHydrated(); + persistClientSettingsPatch({ wordWrap: false }); + await expect(hydration).rejects.toBe(failure); + expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled(); + + persistenceMocks.getClientSettings.mockResolvedValue(savedSettings); + const persisted = new Promise((resolve) => { + persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => { + resolve(settings); + }); + }); + persistClientSettingsPatch({ wordWrap: false }); + + await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false }); + }); + + it("preserves patch order across hydration and a blocked completion write", async () => { + let finishRead!: (settings: ClientSettings) => void; + const read = new Promise((resolve) => { + finishRead = resolve; + }); + persistenceMocks.getClientSettings.mockReturnValue(read); + let finishCompletionWrite!: () => void; + const blockedWrite = new Promise((resolve) => { + finishCompletionWrite = resolve; + }); + let signalCompletionWrite!: () => void; + const completionWriteStarted = new Promise((resolve) => { + signalCompletionWrite = resolve; + }); + let durableSettings: ClientSettings = savedSettings; + const persist = vi + .fn<(settings: ClientSettings) => Promise>() + .mockImplementationOnce(async (settings) => { + signalCompletionWrite(); + await blockedWrite; + durableSettings = settings; + }) + .mockImplementation(async (settings) => { + durableSettings = settings; + }); + + const completion = persistClientSettingsUpdate(complete, persist); + persistClientSettingsPatch({ wordWrap: false }, persist); + finishRead(savedSettings); + await completionWriteStarted; + persistClientSettingsPatch({ wordWrap: true }, persist); + const finalWrite = persistClientSettingsUpdate((current) => current, persist); + + finishCompletionWrite(); + await completion; + await finalWrite; + + const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true }; + expect(getClientSettings()).toEqual(expected); + expect(durableSettings).toEqual(expected); + }); +}); + describe("persistClientSettingsUpdate", () => { it("publishes the update only after persistence succeeds", async () => { let finishPersistence!: () => void; @@ -245,3 +384,40 @@ describe("mergeEnvironmentSettings", () => { expect(settings.sidebarAutoSettleOnMerge).toBe(false); }); }); + +describe("onboarding completion persistence", () => { + it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => { + const failure = new Error("disk full"); + const persist = vi + .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise>() + .mockRejectedValueOnce(failure) + .mockResolvedValue(undefined); + const existingSettings = { + ...DEFAULT_CLIENT_SETTINGS, + timestampFormat: "12-hour" as const, + favorites: [ + { + provider: ProviderInstanceId.make("codex_work"), + model: "gpt-5.6", + }, + ], + }; + __setClientSettingsForTests(existingSettings); + const onboardingCompletedAt = "2026-09-01T12:00:00.000Z"; + const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({ + ...current, + onboardingCompletedAt, + }); + + await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure); + expect(getClientSettings()).toBe(existingSettings); + expect(getClientSettings().onboardingCompletedAt).toBeNull(); + + const completedSettings = { ...existingSettings, onboardingCompletedAt }; + await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual( + completedSettings, + ); + expect(getClientSettings()).toEqual(completedSettings); + expect(persist).toHaveBeenLastCalledWith(completedSettings); + }); +}); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 52a6fec12f0f..9b428b08ea50 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -54,11 +54,13 @@ type UnifiedSettingsPatch = ServerSettingsPatch & ClientSettingsPatch; const clientSettingsListeners = new Set<() => void>(); const clientSettingsHydrationListeners = new Set<() => void>(); +type ClientSettingsHydrationStatus = "pending" | "ready" | "failed" | "retrying"; let clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; -let clientSettingsHydrated = false; +let clientSettingsHydrationStatus: ClientSettingsHydrationStatus = "pending"; let clientSettingsHydrationPromise: Promise | null = null; let clientSettingsHydrationGeneration = 0; let clientSettingsPersistenceQueue: Promise = Promise.resolve(); +let deferredClientSettingsPatchCount = 0; function emitClientSettingsChange() { for (const listener of clientSettingsListeners) { @@ -81,36 +83,40 @@ function replaceClientSettingsSnapshot(settings: ClientSettings): void { emitClientSettingsChange(); } -function setClientSettingsHydrated(nextHydrated: boolean): void { - if (clientSettingsHydrated === nextHydrated) { +function setClientSettingsHydrationStatus(nextStatus: ClientSettingsHydrationStatus): void { + if (clientSettingsHydrationStatus === nextStatus) { return; } - clientSettingsHydrated = nextHydrated; + clientSettingsHydrationStatus = nextStatus; emitClientSettingsHydrationChange(); } function subscribeClientSettings(listener: () => void): () => void { clientSettingsListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsListeners.delete(listener); }; } function getClientSettingsHydratedSnapshot(): boolean { - return clientSettingsHydrated; + return clientSettingsHydrationStatus === "ready"; +} + +function getClientSettingsHydrationStatusSnapshot(): ClientSettingsHydrationStatus { + return clientSettingsHydrationStatus; } function subscribeClientSettingsHydration(listener: () => void): () => void { clientSettingsHydrationListeners.add(listener); - void hydrateClientSettings(); + void hydrateClientSettings().catch(() => undefined); return () => { clientSettingsHydrationListeners.delete(listener); }; } async function hydrateClientSettings(): Promise { - if (clientSettingsHydrated) { + if (clientSettingsHydrationStatus === "ready") { return; } if (clientSettingsHydrationPromise) { @@ -118,6 +124,11 @@ async function hydrateClientSettings(): Promise { } const hydrationGeneration = clientSettingsHydrationGeneration; + setClientSettingsHydrationStatus( + clientSettingsHydrationStatus === "failed" || clientSettingsHydrationStatus === "retrying" + ? "retrying" + : "pending", + ); const nextHydration = (async () => { try { const persistedSettings = await ensureLocalApi().persistence.getClientSettings(); @@ -127,15 +138,16 @@ async function hydrateClientSettings(): Promise { if (persistedSettings) { replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); } + setClientSettingsHydrationStatus("ready"); } catch (error) { + if (hydrationGeneration === clientSettingsHydrationGeneration) { + setClientSettingsHydrationStatus("failed"); + } console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, { operation: "hydrate", ...safeErrorLogAttributes(error), }); - } finally { - if (hydrationGeneration === clientSettingsHydrationGeneration) { - setClientSettingsHydrated(true); - } + throw error; } })(); @@ -165,15 +177,32 @@ export function persistClientSettingsPatch( patch: ClientSettingsPatch, persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): void { - replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); - void enqueueClientSettingsPersistence(() => persist(getClientSettingsSnapshot())).catch( - (error) => { - console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { - operation: "persist", - ...safeErrorLogAttributes(error), - }); - }, - ); + // Patches queued before hydration must publish before newer optimistic patches. + const deferPatch = + clientSettingsHydrationStatus !== "ready" || deferredClientSettingsPatchCount > 0; + if (deferPatch) { + deferredClientSettingsPatchCount += 1; + } else { + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } + void enqueueClientSettingsPersistence(async () => { + if (deferPatch) { + try { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } + replaceClientSettingsSnapshot({ ...getClientSettingsSnapshot(), ...patch }); + } finally { + deferredClientSettingsPatchCount -= 1; + } + } + await persist(getClientSettingsSnapshot()); + }).catch((error) => { + console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, { + operation: "persist", + ...safeErrorLogAttributes(error), + }); + }); } /** @@ -187,6 +216,9 @@ export async function persistClientSettingsUpdate( persist: (settings: ClientSettings) => Promise = defaultClientSettingsPersistence, ): Promise { return enqueueClientSettingsPersistence(async () => { + if (clientSettingsHydrationStatus !== "ready") { + await hydrateClientSettings(); + } for (;;) { const current = getClientSettingsSnapshot(); const next = update(current); @@ -234,7 +266,9 @@ export function getClientSettings(): ClientSettings { } /** - * Resolves once client settings have been read from disk. + * Resolves after settings load or storage confirms no saved settings exist. + * Failed reads reject and remain retryable. They must not allow defaults to + * overwrite saved preferences. * * The pre-hydration snapshot is just the schema defaults, so imperative paths * that open a preview must await this or they bake the built-in viewport, zoom @@ -252,6 +286,14 @@ export function useClientSettingsHydrated(): boolean { ); } +export function useClientSettingsHydrationStatus(): ClientSettingsHydrationStatus { + return useSyncExternalStore( + subscribeClientSettingsHydration, + getClientSettingsHydrationStatusSnapshot, + () => "pending", + ); +} + function useClientSettingsValue(): ClientSettings { return useSyncExternalStore( subscribeClientSettings, @@ -524,9 +566,10 @@ export function useUpdateClientSettings() { export function __resetClientSettingsPersistenceForTests(): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = DEFAULT_CLIENT_SETTINGS; - clientSettingsHydrated = false; + clientSettingsHydrationStatus = "pending"; clientSettingsHydrationPromise = null; clientSettingsPersistenceQueue = Promise.resolve(); + deferredClientSettingsPatchCount = 0; clientSettingsListeners.clear(); clientSettingsHydrationListeners.clear(); } @@ -534,6 +577,6 @@ export function __resetClientSettingsPersistenceForTests(): void { export function __setClientSettingsForTests(settings: ClientSettings): void { clientSettingsHydrationGeneration += 1; clientSettingsSnapshot = settings; - clientSettingsHydrated = true; + clientSettingsHydrationStatus = "ready"; clientSettingsHydrationPromise = null; } diff --git a/apps/web/src/hooks/useTheme.test.ts b/apps/web/src/hooks/useTheme.test.ts index ab87388ff298..9a1748dc4d24 100644 --- a/apps/web/src/hooks/useTheme.test.ts +++ b/apps/web/src/hooks/useTheme.test.ts @@ -204,3 +204,200 @@ describe("theme failure handling", () => { } }); }); + +describe("onboarding theme", () => { + it("clears custom palettes and restores the latest selected theme", async () => { + const storage = createStorage(); + const classes = new Set(); + const styleValues = new Map(); + const root = { + classList: { + add: (name: string) => classes.add(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style: { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }, + }; + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: () => undefined, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + }); + vi.stubGlobal("document", { + body: { style: { backgroundColor: "" } }, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: "rgb(0, 0, 0)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { EMBER_THEME, installCustomTheme } = await import("../themePalette"); + const firstTheme = installCustomTheme({ + ...EMBER_THEME, + id: "first-custom", + label: "First Custom", + }); + const secondTheme = installCustomTheme({ + ...EMBER_THEME, + id: "second-custom", + label: "Second Custom", + colors: { ...EMBER_THEME.colors, error: "#123456" }, + }); + storage.setItem("t3code:theme", firstTheme.id); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(root.dataset.themeId).toBe(firstTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(firstTheme.colors.error); + + const cleanup = mountOnboardingTheme(); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + expect(useTheme().setTheme(secondTheme.id)).toBe(true); + expect(root.dataset.themeId).toBeUndefined(); + expect(styleValues.size).toBe(0); + + cleanup(); + expect(root.dataset.themeId).toBe(secondTheme.id); + expect(styleValues.get("--app-theme-error")).toBe(secondTheme.colors.error); + }); + + it("stays dark during storage changes and restores the latest saved theme", async () => { + const storage = createStorage(); + storage.setItem("t3code:theme", "light"); + const classes = new Set(); + const styleValues = new Map(); + const style = { + backgroundColor: "", + removeProperty: (name: string) => styleValues.delete(name), + setProperty: (name: string, value: string) => styleValues.set(name, value), + }; + const root = { + classList: { + add: (name: string) => classes.add(name), + contains: (name: string) => classes.has(name), + remove: (name: string) => classes.delete(name), + toggle: (name: string, force?: boolean) => { + const next = force ?? !classes.has(name); + if (next) classes.add(name); + else classes.delete(name); + return next; + }, + }, + dataset: {} as Record, + offsetHeight: 0, + style, + }; + const body = { style: { backgroundColor: "" } }; + let storageHandler: ((event: StorageEvent) => void) | undefined; + const setDesktopTheme = vi.fn().mockResolvedValue(undefined); + vi.doMock("react", () => ({ + useCallback: (callback: A) => callback, + useEffect: () => undefined, + useSyncExternalStore: ( + subscribe: (listener: () => void) => () => void, + getSnapshot: () => unknown, + ) => { + subscribe(() => undefined); + return getSnapshot(); + }, + })); + vi.stubGlobal("window", { + addEventListener: (type: string, listener: (event: StorageEvent) => void) => { + if (type === "storage") storageHandler = listener; + }, + localStorage: storage, + matchMedia: () => ({ + matches: false, + addEventListener: () => undefined, + removeEventListener: () => undefined, + }), + removeEventListener: () => undefined, + desktopBridge: { setTheme: setDesktopTheme }, + }); + vi.stubGlobal("document", { + body, + createElement: () => ({ name: "", setAttribute: () => undefined }), + documentElement: root, + head: { append: () => undefined }, + querySelector: () => null, + querySelectorAll: () => [], + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: + root.dataset.onboardingSurface !== undefined + ? "rgb(0, 0, 0)" + : classes.has("dark") + ? "rgb(10, 10, 10)" + : "rgb(255, 255, 255)", + getPropertyValue: () => "", + })); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 0; + }); + + const { mountOnboardingTheme, useTheme } = await import("./useTheme"); + expect(useTheme().resolvedTheme).toBe("light"); + const cleanup = mountOnboardingTheme(); + + expect(root.dataset.onboardingSurface).toBe(""); + expect(classes.has("dark")).toBe(true); + expect(root.style.backgroundColor).toBe("#000"); + expect(body.style.backgroundColor).toBe("#000"); + expect(useTheme().resolvedTheme).toBe("dark"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("dark"); + + storage.setItem("t3code:theme", "dark"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + storage.setItem("t3code:theme", "light"); + storageHandler?.({ key: "t3code:theme" } as StorageEvent); + expect(classes.has("dark")).toBe(true); + expect(useTheme().resolvedTheme).toBe("dark"); + + cleanup(); + expect(root.dataset.onboardingSurface).toBeUndefined(); + expect(classes.has("dark")).toBe(false); + expect(root.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(body.style.backgroundColor).toBe("rgb(255, 255, 255)"); + expect(storage.getItem("t3code:theme")).toBe("light"); + expect(useTheme().resolvedTheme).toBe("light"); + expect(setDesktopTheme).toHaveBeenLastCalledWith("light"); + }); +}); diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 726a03dac7b8..01928552acb0 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -98,6 +98,14 @@ function readStoredThemeHalvesRaw(): { light?: string; dark?: string } { function themeHalvesSignature(halves: ThemeHalves | null): string { return `${halves?.light ?? ""}|${halves?.dark ?? ""}`; } + +function isOnboardingThemeActive(): boolean { + return ( + typeof document !== "undefined" && + document.documentElement.dataset?.onboardingSurface !== undefined + ); +} + const THEME_COLOR_META_NAME = "theme-color"; const DYNAMIC_THEME_COLOR_SELECTOR = `meta[name="${THEME_COLOR_META_NAME}"][data-dynamic-theme-color="true"]`; @@ -292,15 +300,19 @@ function resolveBrowserChromeSurface(): HTMLElement { export function syncBrowserChromeTheme() { if (typeof document === "undefined" || typeof getComputedStyle === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); const rootStyles = getComputedStyle(document.documentElement); - const themeChromeColor = document.documentElement.dataset.themeId - ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) - : null; + const themeChromeColor = + !onboardingActive && document.documentElement.dataset.themeId + ? normalizeThemeColor(rootStyles.getPropertyValue("--app-chrome-background")) + : null; const surfaceColor = normalizeThemeColor( getComputedStyle(resolveBrowserChromeSurface()).backgroundColor, ); const fallbackColor = normalizeThemeColor(getComputedStyle(document.body).backgroundColor); - const backgroundColor = themeChromeColor ?? surfaceColor ?? fallbackColor; + const backgroundColor = onboardingActive + ? "#000" + : (themeChromeColor ?? surfaceColor ?? fallbackColor); if (!backgroundColor) return; document.documentElement.style.backgroundColor = backgroundColor; @@ -321,8 +333,15 @@ export function syncBrowserChromeTheme() { function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview = true } = {}) { if (typeof document === "undefined" || typeof window === "undefined") return; + const onboardingActive = isOnboardingThemeActive(); // Keep the editor's draft visible until an explicit refresh restores the selection. - if (preservePreview && document.documentElement.dataset?.themeId === THEME_PREVIEW_ID) return; + if ( + preservePreview && + !onboardingActive && + document.documentElement.dataset?.themeId === THEME_PREVIEW_ID + ) { + return; + } const appearanceMode = readAppearanceModePreference(theme); const followSystem = appearanceMode === "system"; const systemDark = followSystem ? getSystemDark() : false; @@ -334,7 +353,13 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview lastAppliedTheme.appearanceMode === appearanceMode && themeHalvesSignature(lastAppliedTheme.themeHalves) === themeHalvesSignature(themeHalves) ) { - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } return; } @@ -348,12 +373,19 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview appearanceMode, themeHalves, ); - applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); - const isDark = resolvedAppearance === "dark"; - document.documentElement.classList.toggle("dark", isDark); + if (onboardingActive) { + document.documentElement.classList.add("dark"); + } else { + applyThemePalette(resolveThemeHalf(theme, themeHalves, resolvedAppearance), resolvedAppearance); + document.documentElement.classList.toggle("dark", resolvedAppearance === "dark"); + } lastAppliedTheme = { theme, systemDark, followSystem, appearanceMode, themeHalves }; syncBrowserChromeTheme(); - syncDesktopTheme(theme, followSystem, appearanceMode); + if (onboardingActive) { + syncDesktopTheme("dark", false, "dark"); + } else { + syncDesktopTheme(theme, followSystem, appearanceMode); + } if (suppressTransitions) { // Force a reflow so the no-transitions class takes effect before removal void document.documentElement.offsetHeight; @@ -363,6 +395,28 @@ function applyTheme(theme: Theme, { suppressTransitions = false, preservePreview } } +/** Own the document-wide dark palette used by the first-run wizard and its portals. */ +export function mountOnboardingTheme(): () => void { + if (typeof document === "undefined" || typeof window === "undefined") return () => {}; + + const root = document.documentElement; + applyThemePalette("dark", "dark"); + root.dataset.onboardingSurface = ""; + root.classList.add("dark"); + syncBrowserChromeTheme(); + syncDesktopTheme("dark", false, "dark"); + emitChange(); + + return () => { + delete root.dataset.onboardingSurface; + root.style.backgroundColor = ""; + document.body.style.backgroundColor = ""; + lastAppliedTheme = null; + applyTheme(getStored(), { suppressTransitions: true, preservePreview: false }); + emitChange(); + }; +} + export async function syncDesktopThemePreference( bridge: DesktopThemeBridge, theme: Theme, @@ -424,13 +478,9 @@ function getSnapshot(): ThemeSnapshot { const systemDark = followSystem ? getSystemDark() : false; const themeHalves = readStoredThemeHalves(); - const resolvedTheme = resolveThemeAppearance( - theme, - systemDark, - followSystem, - appearanceMode, - themeHalves, - ); + const resolvedTheme = isOnboardingThemeActive() + ? "dark" + : resolveThemeAppearance(theme, systemDark, followSystem, appearanceMode, themeHalves); if ( lastSnapshot && lastSnapshot.theme === theme && diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 68b0101be28a..45d10f28d735 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -1184,6 +1184,39 @@ html[data-theme-id]:not([data-theme-id=""]) { --terminal-selection-background: var(--app-theme-terminal-selection-background); } +/* The first-run flow owns the whole document so portaled menus and tooltips + use the same fixed palette as the wizard. This follows the theme mapping so + saved custom themes cannot override it while onboarding is mounted. */ +html[data-onboarding-surface]:root { + color-scheme: dark; + --accent: #262626; + --accent-foreground: #fff; + --appearance-contrast-target: #fff; + --app-chrome-background: #000; + --background: #000; + --border: #262626; + --card: #000; + --card-foreground: #fff; + --destructive: var(--color-red-400); + --foreground: #fff; + --icon-muted: #a1a1aa; + --input: #262626; + --muted: #171717; + --muted-foreground: #a1a1aa; + --placeholder: #71717a; + --popover: #171717; + --popover-foreground: #fff; + --ring: #737373; + --secondary: #171717; + --secondary-foreground: #fff; + --secondary-label: #a1a1aa; + --success-foreground: var(--color-emerald-400); + --terminal-background: #000; + --terminal-cursor: #fff; + --terminal-foreground: #fff; + --terminal-selection-background: rgb(255 255 255 / 20%); +} + /* Theme-token dependency probes are restored synchronously, before paint. Keep transitions from observing the temporary sentinel color in between. */ html[data-theme-token-probe], @@ -1385,11 +1418,10 @@ html[data-theme-id="t3-chat"] [data-app-sidebar] { } } -/* Contrast stays in ordinary custom properties so both Tailwind utilities and - global/imperative chrome styles resolve the same adjusted role. Redeclare on - the sidebar because it owns a local semantic palette. */ +/* Recompute contrast wherever a subtree owns its own semantic color palette. */ :root, -[data-app-sidebar] { +[data-app-sidebar], +[data-onboarding-surface] { --contrast-toolbar-foreground: color-mix( in oklab, color-mix( diff --git a/apps/web/src/onboarding/firstRun.logic.test.ts b/apps/web/src/onboarding/firstRun.logic.test.ts new file mode 100644 index 000000000000..ca35f6322e3b --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.test.ts @@ -0,0 +1,514 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + isFirstRunWorkspaceProvenanceAuthoritative, + isFreshFirstRunWorkspace, + resolveFirstRunDecision, + resolveHostedFirstRunDecision, + transitionFirstRunGateState, +} from "./firstRun.logic"; + +const freshWorkspace = { + enabled: true, + hydrated: true, + completed: false, + bootstrapped: true, + authoritative: true, + workspaceAuthoritative: true, + workspaceProvenanceAuthoritative: true, + catalogReady: true, + serverConfigAvailable: true, + workspaceFresh: true, + projectCount: 1, + threadCount: 1, +} as const; + +describe("resolveFirstRunDecision", () => { + it("opens the wizard for an authoritative fresh workspace", () => { + expect(resolveFirstRunDecision(freshWorkspace)).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("does not permanently complete onboarding from cached project counts", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("backfills completion once existing projects are confirmed by the server", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("does not complete onboarding while another environment is still bootstrapping", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + bootstrapped: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for managed environments before treating a workspace as new", () => { + expect(resolveFirstRunDecision({ ...freshWorkspace, catalogReady: false })).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the environment catalog is ready", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + catalogReady: false, + projectCount: 3, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding before the server configuration is available", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + serverConfigAvailable: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from cached remote projects", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + projectCount: 3, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("does not complete onboarding from a single cached remote project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceAuthoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); + + it("waits for live data before judging a single cached project", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + workspaceFresh: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the completed bootstrap welcome before judging a nonempty workspace", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: false, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits when the initial welcome is pending and opens the wizard after completion", () => { + const pendingProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: pendingProvenance, + }), + ).toEqual({ decision: "pending", persistCompletion: false }); + + const completedProvenance = isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }); + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + workspaceProvenanceAuthoritative: completedProvenance, + }), + ).toEqual({ decision: "wizard", persistCompletion: false }); + }); + + it("does not wait for server data after onboarding is already complete", () => { + expect( + resolveFirstRunDecision({ + ...freshWorkspace, + authoritative: false, + bootstrapped: false, + completed: true, + serverConfigAvailable: false, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +describe("isFirstRunWorkspaceProvenanceAuthoritative", () => { + it("waits for cwd bootstrap when the initial catalog is empty", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "pending", + }), + ).toBe(false); + }); + + it("accepts an empty catalog after cwd bootstrap completes", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: "complete", + }), + ).toBe(true); + }); + + it("waits for a welcome before treating an empty catalog as final", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: false, + bootstrapStatus: null, + }), + ).toBe(false); + }); + + it("accepts a legacy welcome without bootstrap status", () => { + expect( + isFirstRunWorkspaceProvenanceAuthoritative({ + welcomeReceived: true, + bootstrapStatus: null, + }), + ).toBe(true); + }); +}); + +describe("transitionFirstRunGateState", () => { + it("shows recovery without mounting the app when evidence stalls", () => { + expect( + transitionFirstRunGateState({ decision: "pending", stalled: false }, { type: "timeout" }), + ).toEqual({ decision: "pending", stalled: true }); + }); + + it.each(["app", "wizard"] as const)( + "resolves stalled recovery to %s only after authoritative evidence", + (decision) => { + expect( + transitionFirstRunGateState( + { decision: "pending", stalled: true }, + { type: "evidence", decision }, + ), + ).toEqual({ decision, stalled: false }); + }, + ); + + it("keeps recovery visible while evidence remains pending", () => { + const state = { decision: "pending", stalled: true } as const; + expect(transitionFirstRunGateState(state, { type: "evidence", decision: "pending" })).toBe( + state, + ); + }); + + it("allows authoritative wizard evidence to replace an app decision", () => { + expect( + transitionFirstRunGateState( + { decision: "app", stalled: false }, + { type: "evidence", decision: "wizard" }, + ), + ).toEqual({ decision: "wizard", stalled: false }); + }); +}); + +describe("resolveHostedFirstRunDecision", () => { + it("keeps the shell hidden until client settings are hydrated", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: false, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("waits for the saved environment catalog before judging a hosted install", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "pending", + persistCompletion: false, + }); + }); + + it("opens onboarding when a hosted install has no saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 0, + }), + ).toEqual({ + decision: "wizard", + persistCompletion: false, + }); + }); + + it("backfills onboarding for a hosted install with saved environments", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: false, + catalogReady: true, + environmentCount: 1, + }), + ).toEqual({ + decision: "app", + persistCompletion: true, + }); + }); + + it("opens the app immediately after hosted onboarding is complete", () => { + expect( + resolveHostedFirstRunDecision({ + hydrated: true, + completed: true, + catalogReady: false, + environmentCount: 0, + }), + ).toEqual({ + decision: "app", + persistCompletion: false, + }); + }); +}); + +const primaryEnvironmentId = "primary-environment"; +const bootstrapProject = { + id: "bootstrap-project", + environmentId: primaryEnvironmentId, + workspaceRoot: "/projects/current", +}; +const bootstrapThread = { + id: "bootstrap-thread", + projectId: bootstrapProject.id, + environmentId: primaryEnvironmentId, + latestTurn: null, + latestUserMessageAt: null, + session: null, +}; + +describe("isFreshFirstRunWorkspace", () => { + it("accepts an empty workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [], + }), + ).toBe(true); + }); + + it("accepts only the unused project and thread created from the server cwd", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current/", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects an existing unused cwd project and thread reused by startup", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: false, + bootstrapThreadCreated: false, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a nonempty workspace when an older server omits creation provenance", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + projects: [bootstrapProject], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("normalizes Windows project paths before checking the bootstrap workspace", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "C:\\Projects\\Current\\", + bootstrapProjectId: bootstrapProject.id, + bootstrapThreadId: bootstrapThread.id, + bootstrapProjectCreated: true, + bootstrapThreadCreated: true, + projects: [{ ...bootstrapProject, workspaceRoot: "c:/projects/current" }], + threads: [bootstrapThread], + }), + ).toBe(true); + }); + + it("rejects projects from another environment even when their paths match", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [{ ...bootstrapProject, environmentId: "remote-environment" }], + threads: [], + }), + ).toBe(false); + }); + + it("rejects threads from another environment", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, environmentId: "remote-environment" }], + }), + ).toBe(false); + }); + + it("rejects a thread that does not belong to the bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, projectId: "another-project" }], + }), + ).toBe(false); + }); + + it("rejects a thread when there is no bootstrap project", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [], + threads: [bootstrapThread], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that already has a user message", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [ + { + ...bootstrapThread, + latestUserMessageAt: "2026-08-23T12:00:00.000Z", + }, + ], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has started a turn", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, latestTurn: { id: "first-turn" } }], + }), + ).toBe(false); + }); + + it("rejects a bootstrap thread that has a provider session", () => { + expect( + isFreshFirstRunWorkspace({ + primaryEnvironmentId, + serverCwd: "/projects/current", + projects: [bootstrapProject], + threads: [{ ...bootstrapThread, session: { status: "ready" } }], + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/onboarding/firstRun.logic.ts b/apps/web/src/onboarding/firstRun.logic.ts new file mode 100644 index 000000000000..013dbb02d527 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.logic.ts @@ -0,0 +1,184 @@ +import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; + +export type FirstRunDecision = "pending" | "app" | "wizard"; + +export interface FirstRunGateState { + readonly decision: FirstRunDecision; + readonly stalled: boolean; +} + +type FirstRunGateEvent = + | { readonly type: "evidence"; readonly decision: FirstRunDecision } + | { readonly type: "timeout" }; + +interface FirstRunWorkspaceInput { + readonly primaryEnvironmentId: string | null; + readonly serverCwd: string | null; + readonly bootstrapProjectId?: string | undefined; + readonly bootstrapThreadId?: string | undefined; + readonly bootstrapProjectCreated?: boolean | undefined; + readonly bootstrapThreadCreated?: boolean | undefined; + readonly projects: ReadonlyArray<{ + readonly id: string; + readonly environmentId: string; + readonly workspaceRoot: string; + }>; + readonly threads: ReadonlyArray<{ + readonly id: string; + readonly projectId: string; + readonly environmentId: string; + readonly latestTurn: unknown; + readonly latestUserMessageAt: string | null; + readonly session: unknown; + }>; +} + +interface FirstRunDecisionInput { + readonly enabled: boolean; + readonly hydrated: boolean; + readonly completed: boolean; + readonly bootstrapped: boolean; + readonly authoritative: boolean; + readonly workspaceAuthoritative: boolean; + readonly workspaceProvenanceAuthoritative: boolean; + readonly catalogReady: boolean; + readonly serverConfigAvailable: boolean; + readonly workspaceFresh: boolean; + readonly projectCount: number; + readonly threadCount: number; +} + +interface HostedFirstRunDecisionInput { + readonly hydrated: boolean; + readonly completed: boolean; + readonly catalogReady: boolean; + readonly environmentCount: number; +} + +export function isFirstRunWorkspaceProvenanceAuthoritative(input: { + readonly welcomeReceived: boolean; + readonly bootstrapStatus: "pending" | "complete" | null; +}): boolean { + // An empty catalog is not final while cwd auto-bootstrap is pending. Older + // servers omit bootstrapStatus, so a received welcome with null stays valid. + return input.welcomeReceived && input.bootstrapStatus !== "pending"; +} + +/** Keeps the authenticated app unmounted until workspace evidence settles. */ +export function transitionFirstRunGateState( + state: FirstRunGateState, + event: FirstRunGateEvent, +): FirstRunGateState { + if (event.type === "timeout") { + return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; + } + + if ( + state.decision === "wizard" || + event.decision === "pending" || + (state.decision === "app" && event.decision !== "wizard") + ) { + return state; + } + + return { decision: event.decision, stalled: false }; +} + +/** Only a project and thread created by this startup count as a fresh nonempty workspace. */ +export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean { + if (input.projects.length > 1 || input.threads.length > 1) { + return false; + } + + const bootstrapProject = input.projects[0]; + if (bootstrapProject !== undefined) { + if ( + input.bootstrapProjectCreated !== true || + input.bootstrapProjectId !== bootstrapProject.id || + input.serverCwd === null || + bootstrapProject.environmentId !== input.primaryEnvironmentId || + normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !== + normalizeProjectPathForComparison(input.serverCwd) + ) { + return false; + } + } + + const bootstrapThread = input.threads[0]; + if (bootstrapThread === undefined) { + return true; + } + + return ( + bootstrapProject !== undefined && + input.bootstrapThreadCreated === true && + input.bootstrapThreadId === bootstrapThread.id && + bootstrapThread.environmentId === input.primaryEnvironmentId && + bootstrapThread.projectId === bootstrapProject.id && + bootstrapThread.latestTurn === null && + bootstrapThread.latestUserMessageAt === null && + bootstrapThread.session === null + ); +} + +/** Cached projects may open the app, but only live workspace data may complete onboarding. */ +export function resolveFirstRunDecision(input: FirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.enabled || (input.hydrated && input.completed)) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.projectCount > 1 || input.threadCount > 1) { + return { + decision: "app", + persistCompletion: + input.bootstrapped && + input.authoritative && + input.workspaceAuthoritative && + input.catalogReady && + input.serverConfigAvailable, + }; + } + + if ( + !input.bootstrapped || + !input.authoritative || + !input.workspaceProvenanceAuthoritative || + !input.catalogReady || + !input.serverConfigAvailable + ) { + return { decision: "pending", persistCompletion: false }; + } + + return input.workspaceFresh + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: input.workspaceAuthoritative }; +} + +/** Hosted onboarding depends on saved environments because there is no primary server. */ +export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { + readonly decision: FirstRunDecision; + readonly persistCompletion: boolean; +} { + if (!input.hydrated) { + return { decision: "pending", persistCompletion: false }; + } + + if (input.completed) { + return { decision: "app", persistCompletion: false }; + } + + if (!input.catalogReady) { + return { decision: "pending", persistCompletion: false }; + } + + return input.environmentCount === 0 + ? { decision: "wizard", persistCompletion: false } + : { decision: "app", persistCompletion: true }; +} diff --git a/apps/web/src/onboarding/firstRun.ts b/apps/web/src/onboarding/firstRun.ts new file mode 100644 index 000000000000..4daafc7cc924 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.ts @@ -0,0 +1,16 @@ +import { useCallback } from "react"; + +import { ensureClientSettingsHydrated, persistClientSettingsUpdate } from "../hooks/useSettings"; + +/** + * Marks first-run onboarding finished (or skipped) so FirstRunGate never + * routes to the welcome wizard again. The gate itself lives in + * `components/onboarding/FirstRunGate.tsx`. + */ +export function useCompleteOnboarding(): () => Promise { + return useCallback(async () => { + await ensureClientSettingsHydrated(); + const onboardingCompletedAt = new Date().toISOString(); + await persistClientSettingsUpdate((current) => ({ ...current, onboardingCompletedAt })); + }, []); +} diff --git a/apps/web/src/onboarding/projectImport.logic.test.ts b/apps/web/src/onboarding/projectImport.logic.test.ts new file mode 100644 index 000000000000..07028abd28b4 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.test.ts @@ -0,0 +1,245 @@ +import { EnvironmentId, ProjectId, type AgentSessionProjectCandidate } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + partitionOnboardingProjects, + resolveOnboardingLandingProject, + resolveOnboardingProjectId, +} from "./projectImport.logic"; + +const now = Date.parse("2026-08-22T12:00:00.000Z"); + +function candidate( + path: string, + overrides: Partial = {}, +): AgentSessionProjectCandidate { + return { + title: path.split("/").at(-1) ?? path, + path, + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-08-20T12:00:00.000Z", + alreadyImported: false, + ...overrides, + }; +} + +describe("partitionOnboardingProjects", () => { + it("keeps existing projects available for thread history import", () => { + const imported = candidate("/projects/current", { alreadyImported: true }); + const available = candidate("/projects/other"); + + expect(partitionOnboardingProjects([imported, available], now)).toEqual({ + available: [imported, available], + recent: [imported, available], + }); + }); + + it("keeps projects older than 30 days out of the default selection", () => { + const recent = candidate("/projects/recent"); + const older = candidate("/projects/older", { + lastActiveAt: "2026-07-01T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, older], now)).toEqual({ + available: [recent, older], + recent: [recent], + }); + }); + + it("keeps future activity out of the default selection", () => { + const recent = candidate("/projects/recent"); + const future = candidate("/projects/future", { + lastActiveAt: "2026-08-23T12:00:00.000Z", + }); + + expect(partitionOnboardingProjects([recent, future], now)).toEqual({ + available: [recent, future], + recent: [recent], + }); + }); +}); + +describe("resolveOnboardingProjectId", () => { + const localEnvironmentId = EnvironmentId.make("local"); + const remoteEnvironmentId = EnvironmentId.make("remote"); + const localProjectId = ProjectId.make("local-project"); + + it("uses the scanned project ID before the project reaches the client", () => { + expect( + resolveOnboardingProjectId( + [], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("uses the scanned project ID when the client still has an older project at that root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("stale-project"), + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo", { projectId: localProjectId }), + ), + ).toBe(localProjectId); + }); + + it("returns null to create a project when neither the scan nor the client has a project ID", () => { + expect( + resolveOnboardingProjectId([], localEnvironmentId, candidate("/projects/new")), + ).toBeNull(); + }); + + it("finds an existing project by normalized root in the target environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "C:\\Work\\Repo", + }, + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "C:\\Work\\Repo\\", + }, + ], + localEnvironmentId, + candidate("c:/work/repo"), + ), + ).toBe(localProjectId); + }); + + it("does not reuse a project from another environment", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: ProjectId.make("remote-project"), + environmentId: remoteEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); + + it("finds an alias after the scanner returns its persisted project root", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/real/projects/repo", + }, + ], + localEnvironmentId, + candidate("/real/projects/repo"), + ), + ).toBe(localProjectId); + }); + + it("finds the current root owner when the scan has no project ID", () => { + const recreatedProjectId = ProjectId.make("recreated-project"); + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/other", + }, + { + id: recreatedProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/repo", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBe(recreatedProjectId); + }); + + it("does not reuse a moved project when the scan has no project ID", () => { + expect( + resolveOnboardingProjectId( + [ + { + id: localProjectId, + environmentId: localEnvironmentId, + workspaceRoot: "/projects/moved", + }, + ], + localEnvironmentId, + candidate("/projects/repo"), + ), + ).toBeNull(); + }); +}); + +describe("resolveOnboardingLandingProject", () => { + it("skips a failed first project for a later project with imported history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/failed", "/projects/imported"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("prefers a partial first import that added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/partial", "/projects/complete"], + new Map([["/projects/partial", "partial"]]), + new Map([["/projects/complete", "complete"]]), + ), + ).toBe("partial"); + }); + + it("uses a completed zero-history project when no import added history", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/empty", "/projects/failed"], + new Map(), + new Map([["/projects/empty", "empty"]]), + ), + ).toBe("empty"); + }); + + it("keeps an earlier successful import available on retry", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/imported", "/projects/retry"], + new Map([["/projects/imported", "imported"]]), + new Map([["/projects/imported", "imported"]]), + ), + ).toBe("imported"); + }); + + it("ignores cached successes outside the current retry selection", () => { + expect( + resolveOnboardingLandingProject( + ["/projects/current"], + new Map([["/projects/previous", "previous"]]), + new Map([ + ["/projects/previous", "previous"], + ["/projects/current", "current"], + ]), + ), + ).toBe("current"); + }); +}); diff --git a/apps/web/src/onboarding/projectImport.logic.ts b/apps/web/src/onboarding/projectImport.logic.ts new file mode 100644 index 000000000000..d723b911b665 --- /dev/null +++ b/apps/web/src/onboarding/projectImport.logic.ts @@ -0,0 +1,55 @@ +import { findProjectByPath } from "@t3tools/client-runtime/state/projects"; +import type { AgentSessionProjectCandidate, EnvironmentId, ProjectId } from "@t3tools/contracts"; + +const RECENT_PROJECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +/** Existing projects still need their agent history imported, so every scan candidate is offered. */ +export function partitionOnboardingProjects( + candidates: ReadonlyArray, + now = Date.now(), +) { + const cutoff = now - RECENT_PROJECT_WINDOW_MS; + + return { + available: candidates, + recent: candidates.filter((candidate) => { + if (candidate.lastActiveAt === null) return false; + const lastActiveAt = Date.parse(candidate.lastActiveAt); + return lastActiveAt >= cutoff && lastActiveAt <= now; + }), + }; +} + +/** Use the server's project match before the client snapshot, which can lag behind the scan. */ +export function resolveOnboardingProjectId( + projects: ReadonlyArray<{ + readonly id: ProjectId; + readonly environmentId: EnvironmentId; + readonly workspaceRoot: string; + }>, + environmentId: EnvironmentId, + candidate: Pick, +): ProjectId | null { + if (candidate.projectId !== undefined) return candidate.projectId; + const environmentProjects = projects.filter((project) => project.environmentId === environmentId); + const currentRootMatch = findProjectByPath(environmentProjects, candidate.path); + if (currentRootMatch !== undefined) return currentRootMatch.id; + return null; +} + +/** Prefer a selected project with imported history, then a completed empty import. */ +export function resolveOnboardingLandingProject( + selection: ReadonlyArray, + projectsWithImportedHistory: ReadonlyMap, + completedProjects: ReadonlyMap, +): T | undefined { + for (const path of selection) { + const project = projectsWithImportedHistory.get(path); + if (project !== undefined) return project; + } + for (const path of selection) { + const project = completedProjects.get(path); + if (project !== undefined) return project; + } + return undefined; +} diff --git a/apps/web/src/onboarding/providerReadiness.logic.test.ts b/apps/web/src/onboarding/providerReadiness.logic.test.ts new file mode 100644 index 000000000000..ab742ac51b44 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.test.ts @@ -0,0 +1,317 @@ +import { + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + getOnboardingProviderState, + resolveOnboardingProviderLoginCommand, + selectOnboardingProvidersByDriver, +} from "./providerReadiness.logic"; + +const readyCodex: ServerProvider = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "unknown" }, + checkedAt: "2026-08-23T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], +}; + +describe("getOnboardingProviderState", () => { + it("treats an enabled Codex provider with ready status and unknown authentication as ready", () => { + expect(getOnboardingProviderState(readyCodex)).toBe("ready"); + }); + + it("treats authenticated providers as ready only when their provider status is ready", () => { + expect(getOnboardingProviderState({ ...readyCodex, auth: { status: "authenticated" } })).toBe( + "ready", + ); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "error", + }), + ).toBe("attention"); + expect( + getOnboardingProviderState({ + ...readyCodex, + auth: { status: "authenticated" }, + status: "warning", + }), + ).toBe("attention"); + }); + + it("offers sign-in only when the server reports an authentication failure", () => { + expect( + getOnboardingProviderState({ + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }), + ).toBe("signIn"); + expect(getOnboardingProviderState({ ...readyCodex, status: "error" })).toBe("attention"); + expect(getOnboardingProviderState({ ...readyCodex, status: "warning" })).toBe("attention"); + }); + + it("does not offer installation or sign-in for disabled providers", () => { + expect(getOnboardingProviderState({ ...readyCodex, enabled: false, installed: false })).toBe( + "disabled", + ); + expect(getOnboardingProviderState({ ...readyCodex, status: "disabled" })).toBe("disabled"); + }); + + it("offers installation only when an enabled provider is missing", () => { + expect(getOnboardingProviderState({ ...readyCodex, installed: false, status: "error" })).toBe( + "install", + ); + }); + + it("waits for a provider snapshot before offering an action", () => { + expect(getOnboardingProviderState(undefined)).toBe("checking"); + }); +}); + +describe("selectOnboardingProvidersByDriver", () => { + it("prefers a ready instance with unknown authentication to an unauthenticated instance", () => { + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([signedOutCodex, readyCodex]).get("codex")).toBe( + readyCodex, + ); + }); + + it("prefers a provider with an actionable sign-in over a failed provider", () => { + const failedCodex: ServerProvider = { ...readyCodex, status: "error" }; + const signedOutCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + status: "error", + auth: { status: "unauthenticated" }, + }; + + expect(selectOnboardingProvidersByDriver([failedCodex, signedOutCodex]).get("codex")).toBe( + signedOutCodex, + ); + }); + + it("prefers installed providers over missing or disabled instances", () => { + const disabledCodex: ServerProvider = { ...readyCodex, enabled: false }; + const missingCodex: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + installed: false, + status: "error", + }; + + expect( + selectOnboardingProvidersByDriver([disabledCodex, missingCodex, readyCodex]).get("codex"), + ).toBe(readyCodex); + }); + + it("handles provider snapshots that have not arrived", () => { + expect(selectOnboardingProvidersByDriver(undefined).size).toBe(0); + }); + + it("keeps a ready custom account when the default account is signed out", () => { + const signedOutDefault: ServerProvider = { + ...readyCodex, + status: "error", + auth: { status: "unauthenticated" }, + }; + const readyCustom: ServerProvider = { + ...readyCodex, + instanceId: ProviderInstanceId.make("codex_work"), + }; + + expect(selectOnboardingProvidersByDriver([signedOutDefault, readyCustom]).get("codex")).toBe( + readyCustom, + ); + }); +}); + +describe("resolveOnboardingProviderLoginCommand", () => { + it("uses the selected Codex account binary", () => { + const provider = { ...readyCodex, instanceId: ProviderInstanceId.make("codex_work") }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/codex-work/bin/codex" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/codex-work/bin/codex login"); + }); + + it("uses the selected Claude account binary", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude_work"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providerInstances: { + [provider.instanceId]: { + driver: provider.driver, + config: { binaryPath: "/opt/claude-work/bin/claude" }, + }, + }, + }, + "linux", + ), + ).toBe("/opt/claude-work/bin/claude auth login"); + }); + + it("quotes a Codex path with spaces for PowerShell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Program Files\\Codex & Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("& 'C:\\Program Files\\Codex & Tools\\codex.exe' login"); + }); + + it("quotes a Claude path with shell metacharacters on POSIX", () => { + const provider: ServerProvider = { + ...readyCodex, + driver: ProviderDriverKind.make("claudeAgent"), + instanceId: ProviderInstanceId.make("claude"), + }; + + expect( + resolveOnboardingProviderLoginCommand( + provider, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { + ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, + binaryPath: "/opt/Claude Tools/$current/claude", + }, + }, + }, + "linux", + ), + ).toBe("'/opt/Claude Tools/$current/claude' auth login"); + }); + + it.each([ + ["~/my tools/codex", "~/'my tools/codex' login"], + ["~\\my tools/codex", "~/'my tools/codex' login"], + ["~/tools/codex's build", `~/'tools/codex'"'"'s build' login`], + ["~\\tools\\codex's build", `~/'tools\\codex'"'"'s build' login`], + ["~/tools/codex; echo unsafe", "~/'tools/codex; echo unsafe' login"], + ])("keeps the home prefix expandable while quoting %s", (binaryPath, expectedCommand) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath, + }, + }, + }, + "linux", + ), + ).toBe(expectedCommand); + }); + + it.each(["darwin", "linux"] as const)("quotes backslashes in a Codex path on %s", (platform) => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/codex\\work/codex", + }, + }, + }, + platform, + ), + ).toBe("'/opt/codex\\work/codex' login"); + }); + + it("keeps a plain Windows path unquoted", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "C:\\Tools\\codex.exe", + }, + }, + }, + "windows", + ), + ).toBe("C:\\Tools\\codex.exe login"); + }); + + it("uses the default command when an old server reports an unknown shell", () => { + expect( + resolveOnboardingProviderLoginCommand( + readyCodex, + { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + codex: { + ...DEFAULT_SERVER_SETTINGS.providers.codex, + binaryPath: "/opt/Codex Tools/codex", + }, + }, + }, + "unknown", + ), + ).toBe("codex login"); + }); +}); diff --git a/apps/web/src/onboarding/providerReadiness.logic.ts b/apps/web/src/onboarding/providerReadiness.logic.ts new file mode 100644 index 000000000000..939b4c64cd64 --- /dev/null +++ b/apps/web/src/onboarding/providerReadiness.logic.ts @@ -0,0 +1,99 @@ +import { + ClaudeSettings, + CodexSettings, + type ExecutionEnvironmentPlatformOs, + type ServerProvider, + type ServerSettings, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const decodeClaudeSettings = Schema.decodeUnknownOption(ClaudeSettings); +const decodeCodexSettings = Schema.decodeUnknownOption(CodexSettings); +const SAFE_SHELL_BINARY_PATTERN = /^[A-Za-z0-9_./:\\-]+$/; + +function quoteProviderBinary( + binaryPath: string, + fallback: string, + platform: ExecutionEnvironmentPlatformOs, +): string { + if ( + SAFE_SHELL_BINARY_PATTERN.test(binaryPath) && + (platform === "windows" || !binaryPath.includes("\\")) + ) { + return binaryPath; + } + if (platform === "windows") return `& '${binaryPath.replaceAll("'", "''")}'`; + if (platform === "darwin" || platform === "linux") { + if (binaryPath.startsWith("~/") || binaryPath.startsWith("~\\")) { + return `~/'${binaryPath.slice(2).replaceAll("'", `'"'"'`)}'`; + } + return `'${binaryPath.replaceAll("'", `'"'"'`)}'`; + } + return fallback; +} + +export function getOnboardingProviderState(provider: ServerProvider | undefined) { + if (provider === undefined) return "checking"; + if (!provider.enabled || provider.status === "disabled") return "disabled"; + if (!provider.installed) return "install"; + if (provider.auth.status === "unauthenticated") return "signIn"; + if (provider.status === "ready") return "ready"; + return "attention"; +} + +const PROVIDER_STATE_PRIORITY = { + checking: 0, + disabled: 1, + install: 2, + attention: 3, + signIn: 4, + ready: 5, +} as const; + +/** Select the most usable configured instance for each provider driver. */ +export function selectOnboardingProvidersByDriver( + providers: ReadonlyArray | null | undefined, +) { + const providersByDriver = new Map(); + + for (const provider of providers ?? []) { + const existing = providersByDriver.get(provider.driver); + if ( + existing === undefined || + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(provider)] > + PROVIDER_STATE_PRIORITY[getOnboardingProviderState(existing)] + ) { + providersByDriver.set(provider.driver, provider); + } + } + + return providersByDriver; +} + +/** Use the selected provider instance's binary when the setup terminal opens its login flow. */ +export function resolveOnboardingProviderLoginCommand( + provider: ServerProvider, + settings: ServerSettings, + platform: ExecutionEnvironmentPlatformOs, +): string { + const instance = settings.providerInstances[provider.instanceId]; + + if (provider.driver === "claudeAgent") { + const config = decodeClaudeSettings( + instance ? (instance.config ?? {}) : settings.providers.claudeAgent, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "claude"; + return `${quoteProviderBinary(binaryPath, "claude", platform)} auth login`; + } + + if (provider.driver === "codex") { + const config = decodeCodexSettings( + instance ? (instance.config ?? {}) : settings.providers.codex, + ); + const binaryPath = Option.isSome(config) ? config.value.binaryPath : "codex"; + return `${quoteProviderBinary(binaryPath, "codex", platform)} login`; + } + + return provider.driver; +} diff --git a/apps/web/src/onboarding/targetEnvironment.logic.test.ts b/apps/web/src/onboarding/targetEnvironment.logic.test.ts new file mode 100644 index 000000000000..9928f83b27db --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.test.ts @@ -0,0 +1,211 @@ +import { + BearerConnectionTarget, + PrimaryConnectionTarget, + RelayConnectionTarget, + SshConnectionTarget, +} from "@t3tools/client-runtime/connection"; +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + isOnboardingRelayEnvironment, + resolveOnboardingTargetEnvironment, +} from "./targetEnvironment.logic"; + +const primaryEnvironment = { + environmentId: EnvironmentId.make("primary"), + connection: { phase: "connected" }, + entry: { + target: new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("primary"), + label: "This computer", + httpBaseUrl: "http://127.0.0.1:3773", + wsBaseUrl: "ws://127.0.0.1:3773", + }), + }, + label: "This computer", +} as const; + +const olderRelay = { + environmentId: EnvironmentId.make("older-remote"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("older-remote"), + label: "Older computer", + }), + }, + label: "Older computer", +} as const; + +const newerRelay = { + environmentId: EnvironmentId.make("newer-relay"), + connection: { phase: "connected" }, + entry: { + target: new RelayConnectionTarget({ + environmentId: EnvironmentId.make("newer-relay"), + label: "New computer", + }), + }, + label: "New computer", +} as const; + +const pairedRemote = { + environmentId: EnvironmentId.make("paired-remote"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("paired-remote"), + label: "Direct computer", + connectionId: "paired-remote", + }), + }, + label: "Direct computer", +} as const; + +const sshEnvironment = { + environmentId: EnvironmentId.make("ssh-remote"), + connection: { phase: "connected" }, + entry: { + target: new SshConnectionTarget({ + environmentId: EnvironmentId.make("ssh-remote"), + label: "SSH computer", + connectionId: "ssh-remote", + }), + }, + label: "SSH computer", +} as const; + +const desktopLocalEnvironment = { + environmentId: EnvironmentId.make("desktop-local-wsl"), + connection: { phase: "connected" }, + entry: { + target: new BearerConnectionTarget({ + environmentId: EnvironmentId.make("desktop-local-wsl"), + label: "WSL", + connectionId: "local:wsl:Ubuntu", + }), + }, + label: "WSL", +} as const; + +describe("resolveOnboardingTargetEnvironment", () => { + it("waits for the exact paired machine instead of using an older connected machine", () => { + const pendingPairedRemote = { ...pairedRemote, connection: { phase: "connecting" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pendingPairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the exact paired machine once it connects", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay, pairedRemote], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBe(pairedRemote); + }); + + it("waits for a newly paired machine that has not appeared in the catalog", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "direct", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: pairedRemote.environmentId, + }), + ).toBeNull(); + }); + + it("uses the primary machine for local onboarding", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [primaryEnvironment, olderRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("does not substitute a remote machine when the local primary is offline", () => { + const offlinePrimary = { ...primaryEnvironment, connection: { phase: "disconnected" } }; + + expect( + resolveOnboardingTargetEnvironment({ + mode: "local", + environments: [offlinePrimary, olderRelay], + primaryEnvironment: offlinePrimary, + pairedEnvironmentId: null, + }), + ).toBeNull(); + }); + + it("uses the newest connected remote when no exact machine was selected", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, olderRelay, newerRelay], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(newerRelay); + }); + + it("ignores direct, SSH, and desktop-managed connections in Connect mode", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [ + primaryEnvironment, + olderRelay, + pairedRemote, + sshEnvironment, + desktopLocalEnvironment, + ], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(olderRelay); + }); + + it("uses the primary computer when no relay connection exists", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment, pairedRemote, sshEnvironment, desktopLocalEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); + + it("falls back to the connected primary when no remote is available", () => { + expect( + resolveOnboardingTargetEnvironment({ + mode: "connect", + environments: [primaryEnvironment], + primaryEnvironment, + pairedEnvironmentId: null, + }), + ).toBe(primaryEnvironment); + }); +}); + +describe("isOnboardingRelayEnvironment", () => { + it("includes only T3 Connect relay targets", () => { + expect( + [olderRelay, pairedRemote, sshEnvironment, desktopLocalEnvironment].filter( + isOnboardingRelayEnvironment, + ), + ).toEqual([olderRelay]); + }); +}); diff --git a/apps/web/src/onboarding/targetEnvironment.logic.ts b/apps/web/src/onboarding/targetEnvironment.logic.ts new file mode 100644 index 000000000000..6045b8c441e2 --- /dev/null +++ b/apps/web/src/onboarding/targetEnvironment.logic.ts @@ -0,0 +1,49 @@ +import type { ConnectionTarget } from "@t3tools/client-runtime/connection"; +import type { EnvironmentId } from "@t3tools/contracts"; + +interface OnboardingEnvironment { + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; + readonly entry: { readonly target: ConnectionTarget }; +} + +export function isOnboardingRelayEnvironment( + environment: Pick, +): boolean { + return environment.entry.target._tag === "RelayConnectionTarget"; +} + +/** Keep a directly paired machine pinned while its initial connection completes. */ +export function resolveOnboardingTargetEnvironment({ + mode, + environments, + primaryEnvironment, + pairedEnvironmentId, +}: { + readonly mode: "local" | "connect" | "direct"; + readonly environments: ReadonlyArray; + readonly primaryEnvironment: TEnvironment | null; + readonly pairedEnvironmentId: EnvironmentId | null; +}): TEnvironment | null { + if (mode === "direct" && pairedEnvironmentId !== null) { + const pairedEnvironment = environments.find( + (environment) => environment.environmentId === pairedEnvironmentId, + ); + return pairedEnvironment?.connection.phase === "connected" ? pairedEnvironment : null; + } + + const connectedRelayEnvironments = environments.filter( + (environment) => + environment.connection.phase === "connected" && isOnboardingRelayEnvironment(environment), + ); + + if (mode === "connect" && connectedRelayEnvironments.length > 0) { + return connectedRelayEnvironments[connectedRelayEnvironments.length - 1] ?? null; + } + + if (primaryEnvironment?.connection.phase === "connected") { + return primaryEnvironment; + } + + return mode === "local" ? null : (connectedRelayEnvironments[0] ?? null); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..5c796f3ab6c8 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as UsageRouteImport } from './routes/usage' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' @@ -30,6 +31,11 @@ import { Route as ChatPullRequestsRouteImport } from './routes/_chat.pull-reques import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const WelcomeRoute = WelcomeRouteImport.update({ + id: '/welcome', + path: '/welcome', + getParentRoute: () => rootRouteImport, +} as any) const UsageRoute = UsageRouteImport.update({ id: '/usage', path: '/usage', @@ -137,6 +143,7 @@ export interface FileRoutesByFullPath { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -157,6 +164,7 @@ export interface FileRoutesByTo { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/pull-requests': typeof ChatPullRequestsRoute '/connect/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -180,6 +188,7 @@ export interface FileRoutesById { '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren '/usage': typeof UsageRoute + '/welcome': typeof WelcomeRoute '/_chat/pull-requests': typeof ChatPullRequestsRoute '/connect_/callback': typeof ConnectCallbackRoute '/projects/$projectKey': typeof ProjectsProjectKeyRoute @@ -204,6 +213,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -224,6 +234,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/welcome' | '/pull-requests' | '/connect/callback' | '/projects/$projectKey' @@ -246,6 +257,7 @@ export interface FileRouteTypes { | '/pair' | '/settings' | '/usage' + | '/welcome' | '/_chat/pull-requests' | '/connect_/callback' | '/projects/$projectKey' @@ -269,12 +281,20 @@ export interface RootRouteChildren { PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren UsageRoute: typeof UsageRoute + WelcomeRoute: typeof WelcomeRoute ConnectCallbackRoute: typeof ConnectCallbackRoute ProjectsProjectKeyRoute: typeof ProjectsProjectKeyRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/welcome': { + id: '/welcome' + path: '/welcome' + fullPath: '/welcome' + preLoaderRoute: typeof WelcomeRouteImport + parentRoute: typeof rootRouteImport + } '/usage': { id: '/usage' path: '/usage' @@ -468,6 +488,7 @@ const rootRouteChildren: RootRouteChildren = { PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, UsageRoute: UsageRoute, + WelcomeRoute: WelcomeRoute, ConnectCallbackRoute: ConnectCallbackRoute, ProjectsProjectKeyRoute: ProjectsProjectKeyRoute, } diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 64e858d186eb..12cdd946f6c4 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -16,6 +16,7 @@ import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; +import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; @@ -97,6 +98,13 @@ function RootRouteView() { const pathname = useLocation({ select: (location) => location.pathname }); const { authGateState } = Route.useRouteContext(); const primaryEnvironmentAuthenticated = authGateState.status === "authenticated"; + const returningFromWelcomeRef = useRef(pathname === "/welcome"); + + useEffect(() => { + if (pathname === "/welcome") { + returningFromWelcomeRef.current = true; + } + }, [pathname]); useEffect(() => { const frame = window.requestAnimationFrame(() => { @@ -116,6 +124,19 @@ function RootRouteView() { ); } + // The welcome wizard is full-screen like /pair, but keeps toasts so its + // connect/import actions can report failures. + if (pathname === "/welcome") { + return ( + + + + + + + ); + } + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ( <> @@ -133,6 +154,10 @@ function RootRouteView() { ); + // FirstRunGate holds back everything below it — including EventRouter, + // whose welcome payload navigates into a thread — until the first-run + // decision is known, so a fresh install renders nothing (not the shell, + // not a flash of threads) before landing on the welcome wizard. return ( @@ -141,21 +166,28 @@ function RootRouteView() { - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - - - - - - - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {appShell} - {/* Above the router: a theme draft is judged by walking the app, so the - editor has to survive navigation away from settings. */} - + + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + + + + + + + {primaryEnvironmentAuthenticated ? ( + + ) : null} + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {appShell} + {/* Above the router: a theme draft is judged by walking the app, so the + editor has to survive navigation away from settings. */} + + ); @@ -381,7 +413,11 @@ function AuthenticatedTracingBootstrap() { return null; } -function EventRouter() { +function EventRouter({ + skipInitialBootstrapNavigation, +}: { + readonly skipInitialBootstrapNavigation: boolean; +}) { const navigate = useNavigate(); const pathname = useLocation({ select: (loc) => loc.pathname }); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); @@ -394,6 +430,7 @@ function EventRouter() { const serverWelcome = useAtomValue(primaryServerWelcomeAtom); const readPathname = useEffectEvent(() => pathname); const handledBootstrapThreadIdRef = useRef(null); + const skipInitialBootstrapNavigationRef = useRef(skipInitialBootstrapNavigation); const handledConfigEventRef = useRef(serverConfigEvent); const [keybindingsToastController] = useState(() => createKeybindingsUpdateToastController({}), @@ -425,6 +462,11 @@ function EventRouter() { if (readPathname() !== "/") { return; } + if (skipInitialBootstrapNavigationRef.current) { + skipInitialBootstrapNavigationRef.current = false; + handledBootstrapThreadIdRef.current = payload.bootstrapThreadId; + return; + } if (handledBootstrapThreadIdRef.current === payload.bootstrapThreadId) { return; } diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index ba96e8986a97..6a53ee024548 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -21,10 +21,11 @@ import { hasCloudPublicConfig } from "~/cloud/publicConfig"; function ChatIndexRouteView() { const { authGateState } = Route.useRouteContext(); - const { environments } = useEnvironments(); + const { environments, isReady } = useEnvironments(); - if (authGateState.status === "hosted-static" && environments.length === 0) { - return ; + if (authGateState.status === "hosted-static") { + if (!isReady) return null; + if (environments.length === 0) return ; } return ; @@ -79,6 +80,8 @@ function IndexDraftLanding() { /> ) : null; } + // First-run routing to the welcome wizard happens in FirstRunGate at the + // root, before this route ever renders. return ; } diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx new file mode 100644 index 000000000000..10caa4dd46fb --- /dev/null +++ b/apps/web/src/routes/welcome.tsx @@ -0,0 +1,45 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; + +/** + * First-run welcome wizard. Full-screen, outside the sidebar shell (the root + * route mounts this path bare, like /pair). Reached only via the first-run + * gate on the index route; visiting it directly after onboarding is harmless — + * finishing again just refreshes the completion flag. + */ +export const Route = createFileRoute("/welcome")({ + beforeLoad: ({ context }) => { + const { authGateState } = context; + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: WelcomeRouteView, +}); + +function WelcomeRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + const openNewThread = useNewThreadHandler(); + // An authenticated gate means a primary server is serving this app — + // desktop, `npx t3`, or a dev server — and that server is "this machine" + // no matter what hostname the browser used. Only hosted-static has no + // local server to offer. + const localAvailable = authGateState.status === "authenticated"; + return ( + { + if (projectRef !== undefined) { + void openNewThread(projectRef, { replace: true }).catch(() => { + void navigate({ to: "/", replace: true }); + }); + return; + } + void navigate({ to: "/", replace: true }); + }} + /> + ); +} diff --git a/apps/web/src/state/agentSessions.ts b/apps/web/src/state/agentSessions.ts new file mode 100644 index 000000000000..996ddb0ea730 --- /dev/null +++ b/apps/web/src/state/agentSessions.ts @@ -0,0 +1,25 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** + * Scan of Claude Code / Codex home directories on an environment, surfacing + * project candidates for the welcome wizard's import step. The scan walks the + * filesystem server-side, so results are cached briefly and refreshed when the + * import step remounts. + */ +export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:agent-sessions:scan", + tag: WS_METHODS.agentSessionsScan, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, +}); + +export const agentSessionImport = createEnvironmentRpcCommand(connectionAtomRuntime, { + label: "environment-data:agent-sessions:import", + tag: WS_METHODS.agentSessionsImport, +}); diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md new file mode 100644 index 000000000000..9a2aa116670d --- /dev/null +++ b/docs/user/welcome-wizard.md @@ -0,0 +1,62 @@ +# Welcome wizard + +T3 Code shows a setup flow when you open a new installation or connect to the +hosted app for the first time. Existing workspaces skip this flow. + +## Choose a connection + +- **This computer** runs agents on the computer that hosts T3 Code. It does not + require an account. +- **T3 Connect** connects computers that are signed in to your account. Run + `npx t3 connect` on each computer you want to add, then start T3 Code or run + `npx t3 serve` so the computer stays available. +- **Pair a server** connects directly to a server on your network or tailnet. + Start the server with `npx t3 serve`, then run `npx t3 pair --tailscale` and + paste the pairing link. You can also run `npx t3 serve --host
` and + use `npx t3 pair` when the server is already reachable on your network. + +If T3 Code cannot confirm the workspace during startup, the setup flow shows +**Still connecting** instead of opening the app. Select **Reload** to try again. + +If T3 Code cannot read your saved settings, it shows **Could not read settings**. +Select **Retry** after storage becomes available. Setup does not replace +unreadable settings with defaults. + +## Check your agents + +T3 Code checks the selected computer for Claude Code and Codex. If an agent is +not installed or signed in, select its action to open a terminal with the +correct command ready to run. Other providers can be enabled in Settings. + +The setup terminal uses the home directory and environment configured for the +selected provider instance. Sensitive values remain redacted in Settings and +terminal metadata while the terminal process can use them. + +## Import your projects + +T3 Code finds directories that Claude Code or Codex has used. The default +selection includes projects active within the last 30 days. Select **Choose** +to include older projects or change the selection. + +A large or malformed history can reach the scan limit. T3 Code keeps the +projects it found and warns when projects or conversations may be missing. + +Imported projects include Codex and Claude conversations active within the last +30 days. You can continue those conversations in T3 Code. + +Conversation import is best effort. T3 Code keeps the first user prompt and the +newest remaining visible user and assistant messages, with 200 messages total. +It omits tool activity and attachments. For Codex, it omits generated setup +context only when a canonical user event and a valid shared turn ID identify the +same user turn. Ambiguous legacy or response-only context stays in the imported +conversation so T3 Code does not remove user text. It reads one conversation at +a time and skips files larger than 16 MiB. It ignores malformed records and skips +unreadable or unparseable conversations. + +Each import attempt reads up to 100 conversation files and 64 MiB per project, +with up to 100,000 input records. Run import again to continue a large batch. +Completed conversations are not imported again. You can continue without the +remaining history. + +You can skip agent setup and project import. Select **Back** to return to a +previous step. diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 4e6baba8bef4..f2141add930f 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -3,10 +3,12 @@ import { EnvironmentId, type RelayClientInstallProgressEvent, type ServerConfigStreamEvent, + type ServerLifecycleStreamEvent, WS_METHODS, } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -27,7 +29,13 @@ import { import * as EnvironmentSupervisor from "../connection/supervisor.ts"; import * as RpcSession from "../rpc/session.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; -import { EnvironmentRpcRequestObserver, request, runStream, subscribe } from "./client.ts"; +import { + EnvironmentRpcRequestObserver, + request, + runStream, + subscribe, + subscribeDynamicWithSession, +} from "./client.ts"; const TARGET = new PrimaryConnectionTarget({ environmentId: EnvironmentId.make("environment-1"), @@ -221,6 +229,72 @@ describe("environment RPC", () => { }), ); + it.effect("keeps the producer session on an old value buffered across a session switch", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const secondSubscribed = yield* Deferred.make(); + const firstValueBlocked = yield* Deferred.make(); + const releaseFirstValue = yield* Deferred.make(); + const firstValue = { source: "first", index: 1 } as unknown as ServerLifecycleStreamEvent; + const bufferedFirstValue = { + source: "first", + index: 2, + } as unknown as ServerLifecycleStreamEvent; + const secondValue = { source: "second", index: 1 } as unknown as ServerLifecycleStreamEvent; + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromIterable([firstValue, bufferedFirstValue])), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(secondSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.make(secondValue)), + Stream.concat(Stream.never), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const { activeSession, supervisor } = yield* makeHarness(); + + const resultFiber = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + () => Effect.succeed({}), + ).pipe( + Stream.mapEffect(([producerSession, value]) => + value === firstValue + ? Deferred.succeed(firstValueBlocked, undefined).pipe( + Effect.andThen(Deferred.await(releaseFirstValue)), + Effect.as([producerSession, value] as const), + ) + : Effect.succeed([producerSession, value] as const), + ), + Stream.take(3), + Stream.runCollect, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.forkChild, + ); + + yield* SubscriptionRef.set(activeSession, Option.some(firstSession)); + yield* Deferred.await(firstSubscribed); + yield* Deferred.await(firstValueBlocked); + yield* SubscriptionRef.set(activeSession, Option.some(secondSession)); + yield* Deferred.await(secondSubscribed); + yield* Deferred.succeed(releaseFirstValue, undefined); + + const result = yield* Fiber.join(resultFiber); + expect(result).toEqual([ + [firstSession, firstValue], + [firstSession, bufferedFirstValue], + [secondSession, secondValue], + ]); + }), + ); + it.effect("keeps durable subscriptions alive across a transport failure and new session", () => Effect.gen(function* () { const subscriptions: string[] = []; diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 0d68d2b2d531..175d633e242f 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -178,15 +178,15 @@ interface SubscriptionOptions { readonly resubscribe?: Stream.Stream; } -export function subscribeDynamic( +function subscribeDynamicMapped( tag: TTag, makeInput: (session: RpcSession) => Effect.Effect>, + mapStream: ( + session: RpcSession, + stream: Stream.Stream, EnvironmentRpcStreamFailure>, + ) => Stream.Stream>, options?: SubscriptionOptions, -): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure, - EnvironmentSupervisor -> { +): Stream.Stream, EnvironmentSupervisor> { return Stream.unwrap( Effect.gen(function* () { const supervisor = yield* EnvironmentSupervisor; @@ -216,10 +216,7 @@ export function subscribeDynamic( EnvironmentRpcStreamValue, EnvironmentRpcStreamFailure >; - const subscribeToSession = (): Stream.Stream< - EnvironmentRpcStreamValue, - EnvironmentRpcStreamFailure - > => + const subscribeToSession = (): Stream.Stream> => Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { @@ -229,7 +226,7 @@ export function subscribeDynamic( method: tag, input, }); - return method(input).pipe( + return mapStream(session, method(input)).pipe( Stream.ensuring(completeObservation), Stream.catchCause((cause) => { const hasOnlyExpectedFailures = @@ -287,6 +284,36 @@ export function subscribeDynamic( ); } +export function subscribeDynamic( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + EnvironmentRpcStreamValue, + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped(tag, makeInput, (_session, stream) => stream, options); +} + +/** Tags each value before `switchMap` can buffer it across a session change. */ +export function subscribeDynamicWithSession( + tag: TTag, + makeInput: (session: RpcSession) => Effect.Effect>, + options?: SubscriptionOptions, +): Stream.Stream< + readonly [session: RpcSession, value: EnvironmentRpcStreamValue], + EnvironmentRpcStreamFailure, + EnvironmentSupervisor +> { + return subscribeDynamicMapped( + tag, + makeInput, + (session, stream) => stream.pipe(Stream.map((value) => [session, value] as const)), + options, + ); +} + export function subscribe( tag: TTag, input: EnvironmentRpcInput, diff --git a/packages/client-runtime/src/state/server.test.ts b/packages/client-runtime/src/state/server.test.ts index 6567726a4809..878f8c902f91 100644 --- a/packages/client-runtime/src/state/server.test.ts +++ b/packages/client-runtime/src/state/server.test.ts @@ -7,8 +7,8 @@ import { } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; -import * as Duration from "effect/Duration"; import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -31,13 +31,15 @@ import * as Persistence from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; import { + applyServerWelcomeEvent, + makeEnvironmentServerWelcomeState, makeEnvironmentServerConfigState, isLegacyUpdateHandoffLoss, matchesServerUpdateReadyEvent, matchesServerUpdateResumeEvent, nudgeReconnectDuringUpdateRestart, - projectServerWelcome, resolveServerConfigValue, + resolveServerWelcomeState, resolveServerUpdateProgressResult, serverUpdateStateForProgressEvent, serverUpdateStateForServerVersion, @@ -494,25 +496,227 @@ describe("server state projection", () => { expect(Option.getOrThrow(downgraded).config.environmentThemes).toBeUndefined(); }); - it("retains welcome when a ready event follows in the same stream chunk", () => { + it("keeps a current welcome on ready and rejects a buffered welcome from the old session", () => { + const firstSession = session({} as WsRpcProtocolClient); + const secondSession = session({} as WsRpcProtocolClient); const welcome = { environment: {} as ServerLifecycleWelcomePayload["environment"], cwd: "/repo", projectName: "repo", } as ServerLifecycleWelcomePayload; - const [afterWelcome] = projectServerWelcome(Option.none(), { + const initial = { + currentSession: firstSession, + welcomeSession: firstSession, + welcome: null, + }; + const afterWelcome = applyServerWelcomeEvent(initial, firstSession, { type: "welcome", payload: welcome, }); - const [afterReady, emitted] = projectServerWelcome(afterWelcome, { + const afterReady = applyServerWelcomeEvent(afterWelcome, firstSession, { type: "ready", payload: {}, }); + const afterSwitch = { ...afterReady, currentSession: secondSession }; + const afterBufferedOldWelcome = applyServerWelcomeEvent(afterSwitch, firstSession, { + type: "welcome", + payload: { ...welcome, cwd: "/stale" }, + }); - expect(Option.getOrThrow(afterReady)).toBe(welcome); - expect(emitted).toEqual([]); + expect(afterReady).toBe(afterWelcome); + expect(resolveServerWelcomeState(afterReady)).toBe(welcome); + expect(afterBufferedOldWelcome).toBe(afterSwitch); + expect(resolveServerWelcomeState(afterBufferedOldWelcome)).toBeNull(); }); + it.effect("checks the authoritative session before accepting a buffered welcome", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe( + Stream.drain, + Stream.concat(Stream.fromQueue(firstEvents)), + ), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const staleWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/stale", + projectName: "stale", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + + // Model the point after the ref changed but before either subscriber + // processed its publication. + supervisorSession.value = Option.some(secondSession); + const handled = yield* SubscriptionRef.changes(state).pipe( + Stream.filter( + (value) => value.currentSession === secondSession || value.welcome === staleWelcome, + ), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: staleWelcome }); + + const next = yield* Fiber.join(handled); + expect(next.currentSession).toBe(secondSession); + expect(resolveServerWelcomeState(next)).toBeNull(); + }), + ); + }), + ); + + it.effect("reads the authoritative session after waiting for the welcome state lock", () => + Effect.gen(function* () { + const firstSubscribed = yield* Deferred.make(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => + Stream.fromEffect(Deferred.succeed(firstSubscribed, undefined)).pipe(Stream.drain), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.never, + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const thirdSession = session({} as WsRpcProtocolClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + yield* Deferred.await(firstSubscribed); + const changed = yield* SubscriptionRef.changes(state).pipe( + Stream.filter((value) => value.currentSession !== firstSession), + Stream.runHead, + Effect.map(Option.getOrThrow), + Effect.forkChild, + ); + + yield* state.semaphore.withPermit( + Effect.gen(function* () { + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + yield* Effect.yieldNow; + yield* Effect.yieldNow; + yield* Effect.yieldNow; + supervisorSession.value = Option.some(thirdSession); + }), + ); + + expect((yield* Fiber.join(changed)).currentSession).toBe(thirdSession); + }), + ); + }), + ); + + it.effect("clears a welcome until the reconnected session sends its own", () => + Effect.gen(function* () { + const firstEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const secondEvents = yield* Queue.unbounded<{ + readonly type: "welcome" | "ready"; + readonly payload: unknown; + }>(); + const firstClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(firstEvents), + } as unknown as WsRpcProtocolClient; + const secondClient = { + [WS_METHODS.subscribeServerLifecycle]: () => Stream.fromQueue(secondEvents), + } as unknown as WsRpcProtocolClient; + const firstSession = session(firstClient); + const secondSession = session(secondClient); + const supervisorSession = yield* SubscriptionRef.make(Option.some(firstSession)); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session: supervisorSession, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + } satisfies EnvironmentSupervisor.EnvironmentSupervisor["Service"]); + const firstWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/first", + projectName: "first", + } as ServerLifecycleWelcomePayload; + const secondWelcome = { + environment: {} as ServerLifecycleWelcomePayload["environment"], + cwd: "/second", + projectName: "second", + } as ServerLifecycleWelcomePayload; + + yield* Effect.scoped( + Effect.gen(function* () { + const state = yield* makeEnvironmentServerWelcomeState().pipe( + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + const nextResolved = ( + predicate: (value: ServerLifecycleWelcomePayload | null) => boolean, + ) => + SubscriptionRef.changes(state).pipe( + Stream.map(resolveServerWelcomeState), + Stream.filter(predicate), + Stream.runHead, + Effect.map(Option.getOrThrow), + ); + + const first = yield* nextResolved((value) => value === firstWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(firstEvents, { type: "welcome", payload: firstWelcome }); + expect(yield* Fiber.join(first)).toBe(firstWelcome); + + const cleared = yield* nextResolved((value) => value === null).pipe(Effect.forkChild); + yield* SubscriptionRef.set(supervisorSession, Option.some(secondSession)); + expect(yield* Fiber.join(cleared)).toBeNull(); + expect(resolveServerWelcomeState(yield* SubscriptionRef.get(state))).toBeNull(); + + const second = yield* nextResolved((value) => value === secondWelcome).pipe( + Effect.forkChild, + ); + yield* Queue.offer(secondEvents, { type: "welcome", payload: secondWelcome }); + expect(yield* Fiber.join(second)).toBe(secondWelcome); + }), + ); + }), + ); + it("prefers an active session config over cache until a live event arrives", () => { const config = (source: string, serverVersion: string) => ({ diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 7ba62a681481..9f94663388ec 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -40,8 +40,10 @@ import { request, runStream, subscribe, + subscribeDynamicWithSession, type EnvironmentRpcInput, } from "../rpc/client.ts"; +import type { RpcSession } from "../rpc/session.ts"; import { followStreamInEnvironment } from "./runtime.ts"; import { applyServerConfigProjection, @@ -476,21 +478,119 @@ export function serverConfigStateChanges( ); } -export function projectServerWelcome( - current: Option.Option, +export function applyServerWelcomeEvent( + current: EnvironmentServerWelcomeState, + session: RpcSession, event: { readonly type: "welcome" | "ready"; readonly payload: unknown; }, -): readonly [ - Option.Option, - ReadonlyArray, -] { - if (event.type !== "welcome") { - return [current, []]; - } - const welcome = event.payload as ServerLifecycleWelcomePayload; - return [Option.some(welcome), [welcome]]; +): EnvironmentServerWelcomeState { + return event.type === "welcome" && current.currentSession === session + ? { + ...current, + welcomeSession: session, + welcome: event.payload as ServerLifecycleWelcomePayload, + } + : current; +} + +export interface EnvironmentServerWelcomeState { + readonly currentSession: RpcSession | null; + readonly welcomeSession: RpcSession | null; + readonly welcome: ServerLifecycleWelcomePayload | null; +} + +export function resolveServerWelcomeState( + state: EnvironmentServerWelcomeState, +): ServerLifecycleWelcomePayload | null { + return state.currentSession === state.welcomeSession ? state.welcome : null; +} + +export const makeEnvironmentServerWelcomeState = Effect.fn("EnvironmentServerWelcomeState.make")( + function* () { + const supervisor = yield* EnvironmentSupervisor; + const initialSession = Option.getOrNull(yield* SubscriptionRef.get(supervisor.session)); + const state = yield* SubscriptionRef.make({ + currentSession: initialSession, + welcomeSession: null, + welcome: null, + }); + + const updateWithCurrentSession = Effect.fn( + "EnvironmentServerWelcomeState.updateWithCurrentSession", + )(function* ( + update: ( + current: EnvironmentServerWelcomeState, + currentSession: RpcSession | null, + ) => EnvironmentServerWelcomeState, + ) { + return yield* SubscriptionRef.modifyEffect(state, (current) => + SubscriptionRef.get(supervisor.session).pipe( + Effect.map( + (latestSession) => + [undefined, update(current, Option.getOrNull(latestSession))] as const, + ), + ), + ); + }); + + yield* SubscriptionRef.changes(supervisor.session).pipe( + Stream.runForEach(() => + updateWithCurrentSession((current, currentSession) => ({ + ...current, + currentSession, + })), + ), + Effect.forkScoped, + ); + + yield* subscribeDynamicWithSession( + WS_METHODS.subscribeServerLifecycle, + Effect.fn("EnvironmentServerWelcomeState.makeSubscribeInput")(function* (session) { + yield* updateWithCurrentSession((current, currentSession) => + currentSession === session + ? { + ...current, + currentSession, + welcomeSession: session, + welcome: null, + } + : { ...current, currentSession }, + ); + return {}; + }), + ).pipe( + Stream.runForEach(([session, event]) => + updateWithCurrentSession((current, currentSession) => + applyServerWelcomeEvent( + { + ...current, + currentSession, + }, + session, + event, + ), + ), + ), + Effect.forkScoped, + ); + + return state; + }, +); + +export function serverWelcomeStateChanges(environmentId: EnvironmentId) { + return followStreamInEnvironment( + environmentId, + Stream.unwrap( + makeEnvironmentServerWelcomeState().pipe( + Effect.map((state) => + SubscriptionRef.changes(state).pipe(Stream.map(resolveServerWelcomeState)), + ), + ), + ), + ); } export function resolveServerConfigValue( @@ -833,6 +933,27 @@ export function createServerEnvironmentAtoms( Atom.withLabel(`environment-data:server:providers:${environmentId}`), ), ); + const welcomeStateFamily = Atom.family((environmentId: EnvironmentId) => + runtime + .atom(serverWelcomeStateChanges(environmentId), { initialValue: null }) + .pipe( + Atom.setIdleTTL(5 * 60_000), + Atom.withLabel(`environment-data:server:welcome-state:${environmentId}`), + ), + ); + const welcomeFamily = Atom.family((environmentId: EnvironmentId) => + Atom.make((get) => { + const result = get(welcomeStateFamily(environmentId)); + if (result._tag !== "Success") return result; + return result.value === null + ? AsyncResult.initial(result.waiting) + : AsyncResult.success(result.value, result); + }).pipe(Atom.withLabel(`environment-data:server:welcome:${environmentId}`)), + ); + const welcome = (target: { + readonly environmentId: EnvironmentId; + readonly input: EnvironmentRpcInput; + }) => welcomeFamily(target.environmentId); return { configValueAtom, @@ -916,14 +1037,7 @@ export function createServerEnvironmentAtoms( refreshTrigger: ({ environmentId }) => usagePricesAtom(environmentId), }), configProjection, - welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { - label: "environment-data:server:welcome", - tag: WS_METHODS.subscribeServerLifecycle, - transform: (stream) => - stream.pipe( - Stream.mapAccum(Option.none, projectServerWelcome), - ), - }), + welcome, consumeResetCredit: createEnvironmentRpcCommand(runtime, { label: "environment-data:server:consume-reset-credit", tag: WS_METHODS.providerConsumeResetCredit, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 9a9be9b4da09..1f52eccc94cd 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -380,6 +380,40 @@ describe("applyThreadDetailEvent", () => { } }); + it("keeps imported replies turnless when delivered again", () => { + const event = { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.message-sent", + payload: { + threadId: baseThread.id, + messageId: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported reply", + turnId: null, + streaming: false, + createdAt: "2026-03-01T06:00:00.000Z", + updatedAt: "2026-03-01T06:00:00.000Z", + }, + } as const; + + const imported = applyThreadDetailEvent(baseThread, event); + expect(imported.kind).toBe("updated"); + if (imported.kind !== "updated") return; + expect(imported.thread.latestTurn).toBeNull(); + expect(imported.thread.checkpoints).toBe(baseThread.checkpoints); + + const repeated = applyThreadDetailEvent(imported.thread, { ...event, sequence: 7 }); + expect(repeated.kind).toBe("updated"); + if (repeated.kind !== "updated") return; + expect(repeated.thread.messages).toEqual(imported.thread.messages); + expect(repeated.thread.latestTurn).toBeNull(); + expect(repeated.thread.checkpoints).toBe(baseThread.checkpoints); + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, @@ -1178,6 +1212,100 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.reverted", () => { + it("keeps imported history and removes the first live prompt at checkpoint zero", () => { + const threadWithImportedHistory: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("import:codex:session-1:000000"), + role: "user", + text: "Imported prompt", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:00:00.000Z", + }, + { + id: MessageId.make("import:codex:session-1:000001"), + role: "assistant", + text: "Imported answer", + turnId: null, + streaming: false, + createdAt: "2026-03-01T00:01:00.000Z", + updatedAt: "2026-03-01T00:01:00.000Z", + }, + { + id: MessageId.make("live-user-message"), + role: "user", + text: "New work", + turnId: null, + streaming: false, + createdAt: "2026-04-01T01:00:00.000Z", + updatedAt: "2026-04-01T01:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithImportedHistory, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T02:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 0 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.text)).toEqual([ + "Imported prompt", + "Imported answer", + ]); + } + }); + + it("fallback-retains the earliest absolute timestamp across offsets", () => { + const threadWithOffsetMessages: OrchestrationThread = { + ...baseThread, + messages: [ + { + id: MessageId.make("earlier-by-offset"), + role: "user", + text: "Earlier", + turnId: null, + streaming: false, + createdAt: "2026-04-01T10:30:00.000+02:00", + updatedAt: "2026-04-01T10:30:00.000+02:00", + }, + { + id: MessageId.make("later-in-utc"), + role: "user", + text: "Later", + turnId: null, + streaming: false, + createdAt: "2026-04-01T09:00:00.000Z", + updatedAt: "2026-04-01T09:00:00.000Z", + }, + ], + }; + + const result = applyThreadDetailEvent(threadWithOffsetMessages, { + ...baseEventFields, + sequence: 14, + occurredAt: "2026-04-01T10:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.reverted", + payload: { threadId: ThreadId.make("thread-1"), turnCount: 1 }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages.map((message) => message.id)).toEqual(["earlier-by-offset"]); + } + }); + it("filters entities to retained turns", () => { const threadWithData: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 1de0b654c060..c237856f90b1 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -12,6 +12,8 @@ import type { OrchestrationThreadActivity, TurnId, } from "@t3tools/contracts"; +import { isImportedAgentSessionMessageId } from "@t3tools/contracts"; +import { compareDateTimeStrings } from "@t3tools/shared/dateTime"; export type ThreadDetailReducerResult = | { readonly kind: "updated"; readonly thread: OrchestrationThread } @@ -551,7 +553,11 @@ export function applyThreadDetailEvent( ); const retainedTurnIds = new Set(Arr.map(checkpoints, (entry) => entry.turnId)); - const messages = retainMessagesAfterRevert(thread.messages, retainedTurnIds); + const messages = retainMessagesAfterRevert( + thread.messages, + retainedTurnIds, + event.payload.turnCount, + ); const proposedPlans = pipe( thread.proposedPlans, Arr.filter((plan) => plan.turnId === null || retainedTurnIds.has(plan.turnId)), @@ -744,16 +750,42 @@ function rebindCheckpointAssistantMessage( function retainMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, + turnCount: number, ): OrchestrationMessage[] { - // Keep messages that belong to a retained turn, plus system messages and - // messages without a turn binding (pre-turn-0 user messages). - return Arr.filter(messages, (message) => { - if (message.role === "system") { - return true; + const retainedMessageIds = new Set(); + for (const message of messages) { + if (message.role === "system" || isImportedAgentSessionMessageId(message.id)) { + retainedMessageIds.add(message.id); + } else if (message.turnId !== null && retainedTurnIds.has(message.turnId)) { + retainedMessageIds.add(message.id); } - if (message.turnId === null) { - return true; + } + + for (const role of ["user", "assistant"] as const) { + const retainedCount = messages.filter( + (message) => + message.role === role && + !isImportedAgentSessionMessageId(message.id) && + retainedMessageIds.has(message.id), + ).length; + const missingCount = Math.max(0, turnCount - retainedCount); + const fallbackMessages = messages + .filter( + (message) => + message.role === role && + !retainedMessageIds.has(message.id) && + (message.turnId === null || retainedTurnIds.has(message.turnId)), + ) + .toSorted( + (left, right) => + compareDateTimeStrings(left.createdAt, right.createdAt) || + left.id.localeCompare(right.id), + ) + .slice(0, missingCount); + for (const message of fallbackMessages) { + retainedMessageIds.add(message.id); } - return retainedTurnIds.has(message.turnId); - }); + } + + return Arr.filter(messages, (message) => retainedMessageIds.has(message.id)); } diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts new file mode 100644 index 000000000000..ffd90dd79db8 --- /dev/null +++ b/packages/contracts/src/agentSessions.ts @@ -0,0 +1,98 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, NonNegativeInt, ProjectId, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +/** Coding agent home directories the scanner knows how to read. */ +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export type AgentSessionSource = typeof AgentSessionSource.Type; + +/** File identity saved with an imported session so bounded retries can skip unchanged history. */ +export const AgentSessionImportSource = Schema.Struct({ + provider: AgentSessionSource, + providerInstanceId: ProviderInstanceId, + providerSessionId: TrimmedNonEmptyString, + filePath: TrimmedNonEmptyString, + size: NonNegativeInt, + mtimeMs: Schema.NullOr(Schema.Number), + device: Schema.Number, + inode: Schema.NullOr(Schema.Number), + birthtimeMs: Schema.NullOr(Schema.Number), +}); +export type AgentSessionImportSource = typeof AgentSessionImportSource.Type; + +/** Imported message ids retain their origin after event metadata is projected into SQLite. */ +export function isImportedAgentSessionMessageId(messageId: string): boolean { + return messageId.startsWith("import:"); +} + +/** + * Empty for now. Kept as a struct so future scan options (source filters, + * explicit roots) can be added without a new method. + */ +export const AgentSessionScanInput = Schema.Struct({}); +export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; + +/** + * A directory that at least one agent CLI has run in, suitable for import as a + * T3 Code project. `alreadyImported` marks candidates that already have an + * active project rooted at the same path. + */ +export const AgentSessionProjectCandidate = Schema.Struct({ + path: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + projectId: Schema.optional(ProjectId), + sources: Schema.Array(AgentSessionSource), + threadCount: NonNegativeInt, + lastActiveAt: Schema.NullOr(IsoDateTime), + alreadyImported: Schema.Boolean, +}); +export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; + +export const AgentSessionScanResult = Schema.Struct({ + candidates: Schema.Array(AgentSessionProjectCandidate), + scannedAt: IsoDateTime, + truncated: Schema.optional(Schema.Boolean), +}); +export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; + +export const AgentSessionImportInput = Schema.Struct({ + projectId: ProjectId, + expectedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), +}); +export type AgentSessionImportInput = typeof AgentSessionImportInput.Type; + +export class AgentSessionImportProjectNotFoundError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectNotFoundError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' does not exist.`; + } +} + +export class AgentSessionImportProjectChangedError extends Schema.TaggedErrorClass()( + "AgentSessionImportProjectChangedError", + { projectId: ProjectId }, +) { + override get message(): string { + return `Project '${this.projectId}' changed directories. Scan for projects again before importing history.`; + } +} + +export const AgentSessionImportResult = Schema.Struct({ + importedCount: NonNegativeInt, + skippedCount: NonNegativeInt, +}); +export type AgentSessionImportResult = typeof AgentSessionImportResult.Type; + +export class AgentSessionScanError extends Schema.TaggedErrorClass()( + "AgentSessionScanError", + { + operation: Schema.Literals(["read-settings", "read-projects"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to scan agent sessions during ${this.operation}.`; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 9d7cfd30d628..74a1b4939f1a 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -29,6 +29,7 @@ export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./agentSessions.ts"; export * from "./assets.ts"; export * from "./review.ts"; export * from "./browserImport.ts"; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index d7e33ebb713b..11c21deecddb 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -1135,6 +1135,21 @@ it.effect("project icon overrides accept Lucide icons, colors, and emoji", () => }), ); +it.effect("rejects thread history imports without messages", () => + Effect.gen(function* () { + const result = yield* Effect.exit( + decodeOrchestrationCommand({ + type: "thread.history.import", + commandId: "command-empty-history", + threadId: "thread-1", + messages: [], + }), + ); + + assert.strictEqual(result._tag, "Failure"); + }), +); + it("isProviderSendTurnSupportedImageMimeType accepts raster formats and rejects svg", () => { assert.strictEqual(isProviderSendTurnSupportedImageMimeType("image/png"), true); assert.strictEqual(isProviderSendTurnSupportedImageMimeType("IMAGE/JPEG"), true); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 17cadc6d1d7f..92e9fe01dd42 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -800,6 +800,7 @@ const ThreadCreateCommand = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), createdAt: IsoDateTime, + historyImport: Schema.optional(Schema.Literal(true)), }); const ThreadDeleteCommand = Schema.Struct({ @@ -1122,6 +1123,20 @@ const ThreadMessageAssistantCompleteCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadHistoryImportCommand = Schema.Struct({ + type: Schema.Literal("thread.history.import"), + commandId: CommandId, + threadId: ThreadId, + messages: Schema.Array( + Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + createdAt: IsoDateTime, + }), + ).check(Schema.isNonEmpty()), +}); + const ThreadProposedPlanUpsertCommand = Schema.Struct({ type: Schema.Literal("thread.proposed-plan.upsert"), commandId: CommandId, @@ -1173,6 +1188,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, + ThreadHistoryImportCommand, ThreadProposedPlanUpsertCommand, ThreadTurnDiffCompleteCommand, ThreadActivityAppendCommand, @@ -1473,6 +1489,7 @@ export const OrchestrationEventMetadata = Schema.Struct({ adapterKey: Schema.optional(TrimmedNonEmptyString), requestId: Schema.optional(ApprovalRequestId), ingestedAt: Schema.optional(IsoDateTime), + historyImport: Schema.optional(Schema.Boolean), origin: Schema.optional(OrchestrationClientOrigin), }); export type OrchestrationEventMetadata = typeof OrchestrationEventMetadata.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index f7f2c2b6faa7..4e8ae2e54134 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -28,6 +28,15 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { + AgentSessionImportInput, + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionImportResult, + AgentSessionScanInput, + AgentSessionScanResult, + AgentSessionScanError, +} from "./agentSessions.ts"; import { AssetAccessError, AssetCreateUrlInput, @@ -240,6 +249,8 @@ export const WS_METHODS = { // Filesystem methods filesystemBrowse: "filesystem.browse", + agentSessionsScan: "agentSessions.scan", + agentSessionsImport: "agentSessions.import", assetsCreateUrl: "assets.createUrl", attachmentsCreateUploadUrl: "attachments.createUploadUrl", attachmentsDelete: "attachments.delete", @@ -823,6 +834,23 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { + payload: AgentSessionScanInput, + success: AgentSessionScanResult, + error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), +}); + +export const WsAgentSessionsImportRpc = Rpc.make(WS_METHODS.agentSessionsImport, { + payload: AgentSessionImportInput, + success: AgentSessionImportResult, + error: Schema.Union([ + AgentSessionImportProjectChangedError, + AgentSessionImportProjectNotFoundError, + AgentSessionScanError, + EnvironmentAuthorizationError, + ]), +}); + export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -1241,6 +1269,8 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAgentSessionsScanRpc, + WsAgentSessionsImportRpc, WsAssetsCreateUrlRpc, WsAttachmentsCreateUploadUrlRpc, WsAttachmentsDeleteRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index ba0d6679cfc2..3ea7bed8f1c4 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -748,8 +748,11 @@ export const ServerLifecycleWelcomePayload = Schema.Struct({ environment: ExecutionEnvironmentDescriptor, cwd: TrimmedNonEmptyString, projectName: TrimmedNonEmptyString, + bootstrapStatus: Schema.optional(Schema.Literals(["pending", "complete"])), bootstrapProjectId: Schema.optional(ProjectId), bootstrapThreadId: Schema.optional(ThreadId), + bootstrapProjectCreated: Schema.optional(Schema.Boolean), + bootstrapThreadCreated: Schema.optional(Schema.Boolean), }); export type ServerLifecycleWelcomePayload = typeof ServerLifecycleWelcomePayload.Type; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 50923423352b..623780c1fb8b 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -286,6 +286,13 @@ export const ClientSettingsSchema = Schema.Struct({ // Grayscale `-webkit-font-smoothing: antialiased` (thinner strokes); // disabling restores the platform's heavier default. No effect off macOS. fontSmoothing: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + // When the first-run welcome wizard finished (or was skipped), as an ISO + // timestamp. `null` alone does not mean "show the wizard" — every install + // that predates this field decodes to `null` — so the gate also requires an + // empty workspace before it treats the client as a fresh install. + onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -1188,6 +1195,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffLayout: Schema.optionalKey(DiffLayout), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), fontSizeInterface: Schema.optionalKey(InterfaceFontSize), fontSizePrompt: Schema.optionalKey(PromptFontSize), fontSizeCode: Schema.optionalKey(CodeFontSize), diff --git a/packages/contracts/src/terminal.test.ts b/packages/contracts/src/terminal.test.ts index a08ed4923888..066253602a49 100644 --- a/packages/contracts/src/terminal.test.ts +++ b/packages/contracts/src/terminal.test.ts @@ -7,12 +7,18 @@ import { TerminalClearInput, TerminalCloseInput, TerminalEvent, + TerminalError, TerminalOpenInput, + TerminalProviderEnvironmentError, TerminalResizeInput, TerminalSessionSnapshot, TerminalThreadInput, TerminalWriteInput, } from "./terminal.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; + +const encodeTerminalError = Schema.encodeUnknownSync(TerminalError); +const decodeTerminalError = Schema.decodeUnknownSync(TerminalError); function decodeSync(schema: S, input: unknown): Schema.Schema.Type { return Schema.decodeUnknownSync(schema as never)(input) as Schema.Schema.Type; @@ -27,6 +33,28 @@ function decodes(schema: S, input: unknown): boolean { } } +describe("TerminalProviderEnvironmentError", () => { + it("round-trips its required cause without exposing it in the message", () => { + const cause = { operation: "read-secret", detail: "secret backend unavailable" }; + const error = new TerminalProviderEnvironmentError({ + providerInstanceId: ProviderInstanceId.make("codex_work"), + cause, + }); + const encoded = encodeTerminalError(error); + const decoded = decodeTerminalError(encoded); + + expect(decoded).toMatchObject({ + _tag: "TerminalProviderEnvironmentError", + providerInstanceId: "codex_work", + cause, + }); + expect(decoded.message).toBe( + "Could not prepare the terminal environment for provider instance: codex_work", + ); + expect(decoded.message).not.toContain("secret backend unavailable"); + }); +}); + describe("TerminalOpenInput", () => { it("accepts valid open input", () => { expect( @@ -87,12 +115,14 @@ describe("TerminalOpenInput", () => { T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }, + providerInstanceId: "codex_work", }); expect(parsed.env).toMatchObject({ T3CODE_PROJECT_ROOT: "/tmp/project", CUSTOM_FLAG: "1", }); expect(parsed.worktreePath).toBe("/tmp/project/.t3/worktrees/feature-a"); + expect(parsed.providerInstanceId).toBe("codex_work"); }); it("rejects invalid env keys", () => { @@ -108,6 +138,19 @@ describe("TerminalOpenInput", () => { }), ).toBe(false); }); + + it("rejects invalid provider instance ids", () => { + for (const providerInstanceId of ["", "1invalid", "invalid id"]) { + expect( + decodes(TerminalOpenInput, { + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + cwd: "/tmp/project", + providerInstanceId, + }), + ).toBe(false); + } + }); }); describe("TerminalAttachInput", () => { diff --git a/packages/contracts/src/terminal.ts b/packages/contracts/src/terminal.ts index fa5f18211695..36e3d339f521 100644 --- a/packages/contracts/src/terminal.ts +++ b/packages/contracts/src/terminal.ts @@ -1,5 +1,6 @@ import * as Schema from "effect/Schema"; import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { ProviderInstanceId } from "./providerInstance.ts"; /** * Client-side id for the first shell opened on a thread. Ids are uniformly @@ -43,8 +44,9 @@ export const TerminalOpenInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalOpenInput = Schema.Codec.Encoded; +export type TerminalOpenInput = typeof TerminalOpenInput.Type; export const TerminalAttachInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -53,9 +55,10 @@ export const TerminalAttachInput = Schema.Struct({ cols: Schema.optional(TerminalColsSchema), rows: Schema.optional(TerminalRowsSchema), env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), restartIfNotRunning: Schema.optional(Schema.Boolean), }); -export type TerminalAttachInput = Schema.Codec.Encoded; +export type TerminalAttachInput = typeof TerminalAttachInput.Type; export const TerminalWriteInput = Schema.Struct({ ...TerminalSessionInput.fields, @@ -80,8 +83,9 @@ export const TerminalRestartInput = Schema.Struct({ cols: TerminalColsSchema, rows: TerminalRowsSchema, env: Schema.optional(TerminalEnvSchema), + providerInstanceId: Schema.optional(ProviderInstanceId), }); -export type TerminalRestartInput = Schema.Codec.Encoded; +export type TerminalRestartInput = typeof TerminalRestartInput.Type; export const TerminalCloseInput = Schema.Struct({ ...TerminalThreadInput.fields, @@ -299,6 +303,29 @@ export class TerminalSessionLookupError extends Schema.TaggedErrorClass()( + "TerminalProviderInstanceNotFoundError", + { + providerInstanceId: ProviderInstanceId, + }, +) { + override get message() { + return `Provider instance is not available: ${this.providerInstanceId}`; + } +} + +export class TerminalProviderEnvironmentError extends Schema.TaggedErrorClass()( + "TerminalProviderEnvironmentError", + { + providerInstanceId: ProviderInstanceId, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Could not prepare the terminal environment for provider instance: ${this.providerInstanceId}`; + } +} + export class TerminalNotRunningError extends Schema.TaggedErrorClass()( "TerminalNotRunningError", { @@ -345,6 +372,8 @@ export const TerminalError = Schema.Union([ TerminalCwdError, TerminalHistoryError, TerminalSessionLookupError, + TerminalProviderInstanceNotFoundError, + TerminalProviderEnvironmentError, TerminalNotRunningError, TerminalWriteError, TerminalResizeError, diff --git a/packages/shared/package.json b/packages/shared/package.json index fd932b8b146b..d6915120b806 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -270,6 +270,10 @@ "./hostClassification": { "types": "./src/hostClassification.ts", "import": "./src/hostClassification.ts" + }, + "./dateTime": { + "types": "./src/dateTime.ts", + "import": "./src/dateTime.ts" } }, "scripts": { diff --git a/packages/shared/src/dateTime.test.ts b/packages/shared/src/dateTime.test.ts new file mode 100644 index 000000000000..562507de3ac2 --- /dev/null +++ b/packages/shared/src/dateTime.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { compareDateTimeStrings } from "./dateTime.ts"; + +describe("compareDateTimeStrings", () => { + it("compares valid date-time strings by absolute time", () => { + expect( + compareDateTimeStrings("2026-09-01T12:00:00.000Z", "2026-09-01T05:00:00.000-07:00"), + ).toBe(0); + expect( + compareDateTimeStrings("2026-09-01T12:00:01.000Z", "2026-09-01T12:00:00.000Z"), + ).toBeGreaterThan(0); + }); + + it.each([ + ["2024-02-29T12:00:00Z", "2024-02-29T17:30:00+05:30"], + ["2000-02-29T00:00:00.100Z", "2000-02-28T20:30:00.1-03:30"], + ["0000-01-01T00:00:00.000Z", "+000000-01-01T00:00:00.000+00:00"], + ["+010000-01-01T00:00:00.000Z", "9999-12-31T23:00:00.000-01:00"], + ["2026-09-01T24:00:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00:00.0000Z", "2026-09-02T00:00:00.000Z"], + ["2024-02-29T24:00:00+05:30", "2024-03-01T00:00:00+05:30"], + ["2026-12-31T24:00:00-07:00", "2027-01-01T07:00:00Z"], + ["2026-09-01T12:00Z", "2026-09-01T12:00:00.000Z"], + ["2026-09-01T05:00-07:00", "2026-09-01T12:00:00Z"], + ["2026-09-01T24:00Z", "2026-09-02T00:00:00Z"], + ["2026-09-01T24:00+05:30", "2026-09-02T00:00:00+05:30"], + ])("preserves equal ISO instants %s and %s", (left, right) => { + expect(compareDateTimeStrings(left, right)).toBe(0); + }); + + it("sorts malformed values before valid values", () => { + expect(compareDateTimeStrings("invalid", "2026-09-01T12:00:00.000Z")).toBeLessThan(0); + expect(compareDateTimeStrings("2026-09-01T12:00:00.000Z", "invalid")).toBeGreaterThan(0); + }); + + it.each([ + "2014-02-30", + "2014-03-02", + "2014-03-02T00:00:00", + "2014-03-02T00:00:00.000", + "03/02/2014", + "March 2, 2014", + "Sun, 02 Mar 2014 00:00:00 GMT", + "2014-03-02T00:00:00.000Z\n", + "2014-02-30T00:00:00.000Z", + "1900-02-29T00:00:00.000-07:00", + "2024-04-31T00:00:00.000+05:30", + "2024-03-02T12:00:00.000+24:00", + "2026-09-01T24:01:00Z", + "2026-09-01T24:00:01Z", + "2026-09-01T24:00:00.0001Z", + "2026-09-01T24:01Z", + "2026-09-01T25:00Z", + ])("treats %s as malformed without native date guessing", (malformed) => { + const valid = "1970-01-01T00:00:00.000Z"; + expect(compareDateTimeStrings(malformed, valid)).toBeLessThan(0); + expect(compareDateTimeStrings(valid, malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, "invalid")).toBeLessThan(0); + expect(compareDateTimeStrings("invalid", malformed)).toBeGreaterThan(0); + expect(compareDateTimeStrings(malformed, malformed)).toBe(0); + }); + + it("uses code-unit order for malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid-a", "invalid-B")).toBeGreaterThan(0); + expect(compareDateTimeStrings("invalid-B", "invalid-a")).toBeLessThan(0); + }); + + it("returns zero for equal malformed date-time strings", () => { + expect(compareDateTimeStrings("invalid", "invalid")).toBe(0); + }); + + it("gives every permutation of mixed values the same order", () => { + const early = "2026-09-01T12:00:00.000+14:00"; + const late = "2026-09-01T00:00:00.000-12:00"; + const malformed = "2026-09-01T06:invalid"; + const expected = [malformed, early, late]; + + const permutations = [ + [early, late, malformed], + [early, malformed, late], + [late, early, malformed], + [late, malformed, early], + [malformed, early, late], + [malformed, late, early], + ]; + + for (const values of permutations) { + expect(values.toSorted(compareDateTimeStrings)).toEqual(expected); + } + }); +}); diff --git a/packages/shared/src/dateTime.ts b/packages/shared/src/dateTime.ts new file mode 100644 index 000000000000..544dd2d0d73b --- /dev/null +++ b/packages/shared/src/dateTime.ts @@ -0,0 +1,38 @@ +import * as DateTime from "effect/DateTime"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +const isZonedIsoDateTime = Schema.is( + Schema.String.check( + Schema.isPattern( + /^(?:\d{4}|[+-]\d{6})-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])T(?:(?:[01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d+)?)?|24:00(?::00(?:\.0+)?)?)(?:Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)$/, + ), + Schema.isTrimmed(), + ), +); + +function parseTimestamp(value: string): number { + if (!isZonedIsoDateTime(value)) return Number.NaN; + + // Engines can normalize invalid calendar dates instead of rejecting them. + const datePart = value.slice(0, value.indexOf("T")); + const date = DateTime.make(`${datePart}T00:00:00.000Z`); + if (Option.isNone(date)) return Number.NaN; + const parts = DateTime.toPartsUtc(date.value); + if (parts.month !== Number(datePart.slice(-5, -3)) || parts.day !== Number(datePart.slice(-2))) { + return Number.NaN; + } + return Date.parse(value); +} + +/** Compare date-time strings by absolute time, with stable handling for malformed stored values. */ +export function compareDateTimeStrings(left: string, right: string): number { + const leftTimestamp = parseTimestamp(left); + const rightTimestamp = parseTimestamp(right); + const leftIsValid = !Number.isNaN(leftTimestamp); + const rightIsValid = !Number.isNaN(rightTimestamp); + + if (leftIsValid !== rightIsValid) return leftIsValid ? 1 : -1; + if (leftIsValid) return leftTimestamp - rightTimestamp; + return left < right ? -1 : left > right ? 1 : 0; +} From 2271a27dad1205b403a66af01461f856986e5064 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 03:07:18 -0700 Subject: [PATCH 116/320] fix(server): keep Homebrew mise shims manual-only (#10085) --- .../src/provider/Drivers/CodexDriver.test.ts | 151 ++++++++++++++++++ .../src/provider/providerMaintenance.test.ts | 43 +++-- .../src/provider/providerMaintenance.ts | 4 + 3 files changed, 184 insertions(+), 14 deletions(-) diff --git a/apps/server/src/provider/Drivers/CodexDriver.test.ts b/apps/server/src/provider/Drivers/CodexDriver.test.ts index 7e2f2f8864b6..bac34db452fd 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.test.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.test.ts @@ -8,7 +8,10 @@ import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Sink from "effect/Sink"; +import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; +import * as ChildProcess from "effect/unstable/process/ChildProcess"; import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; @@ -17,6 +20,11 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import { layerTest as codexResetCreditLayerTest } from "../Layers/codexResetCredit.ts"; import { NoOpProviderEventLoggers, ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import * as ModelManifest from "../ModelManifest.ts"; +import { + createProviderVersionAdvisory, + ProviderVersionCache, + resolveLatestProviderVersion, +} from "../providerMaintenance.ts"; import { CodexDriver } from "./CodexDriver.ts"; const testLayer = ServerConfig.layerTest(process.cwd(), { @@ -218,4 +226,147 @@ it.layer(testLayer)("CodexDriver", (it) => { ), ); } + + it.effect.each([ + { + name: "conventional shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "custom data directory", + dataRoot: "custom-tool-data", + commandName: "codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "renamed configured command", + dataRoot: "mise", + commandName: "custom-codex", + version: "0.153.4", + nodeFirst: false, + }, + { + name: "outdated provider", + dataRoot: "mise", + commandName: "codex", + version: "0.153.3", + nodeFirst: false, + }, + { + name: "npm before shim", + dataRoot: "mise", + commandName: "codex", + version: "0.153.4", + nodeFirst: true, + }, + ])( + "does not mistake Homebrew mise for Codex's installer: $name", + (fixture) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-codex-mise-shim-" }); + const brewPrefix = NodePath.join(tempDir, "homebrew"); + const brewPath = NodePath.join(brewPrefix, "bin", "brew"); + const misePath = NodePath.join(brewPrefix, "Cellar", "mise", "2026.9.1", "bin", "mise"); + const shimDir = NodePath.join(tempDir, fixture.dataRoot, "shims"); + const npmPrefix = NodePath.join(tempDir, "mise", "installs", "node", "24.13.0"); + const npmBin = NodePath.join(npmPrefix, "bin"); + const npmEntry = NodePath.join( + npmPrefix, + "lib", + "node_modules", + "@openai", + "codex", + "bin", + "codex.js", + ); + for (const file of [brewPath, misePath, npmEntry]) { + yield* fs.makeDirectory(NodePath.dirname(file), { recursive: true }); + yield* fs.writeFileString(file, "#!/bin/sh\n"); + yield* fs.chmod(file, 0o755); + } + yield* fs.makeDirectory(shimDir, { recursive: true }); + yield* fs.makeDirectory(npmBin, { recursive: true }); + yield* fs.symlink(misePath, NodePath.join(shimDir, fixture.commandName)); + yield* fs.symlink(npmEntry, NodePath.join(npmBin, fixture.commandName)); + const lookupPath = [ + ...(fixture.nodeFirst ? [npmBin, shimDir] : [shimDir, npmBin]), + NodePath.dirname(brewPath), + ].join(NodePath.delimiter); + const probes: Array> = []; + const metadataSpawner = ChildProcessSpawner.make((command) => { + if (!ChildProcess.isStandardCommand(command) || command.command !== brewPath) { + return Effect.die("Provider resolution must not execute a provider or updater"); + } + probes.push(command.args); + const stdout = + command.args[0] === "--prefix" + ? brewPrefix + : JSON.stringify({ formulae: [{ versions: { stable: "2026.9.1" } }] }); + return Effect.succeed( + ChildProcessSpawner.makeHandle({ + pid: ChildProcessSpawner.ProcessId(1), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), + isRunning: Effect.succeed(false), + kill: () => Effect.void, + unref: Effect.succeed(Effect.void), + stdin: Sink.drain, + stdout: Stream.encodeText(Stream.make(stdout)), + stderr: Stream.empty, + all: Stream.empty, + getInputFd: () => Sink.drain, + getOutputFd: () => Stream.empty, + }), + ); + }); + const instance = yield* CodexDriver.create({ + instanceId: ProviderInstanceId.make("codex-mise-shim"), + displayName: "Codex shim test", + enabled: false, + environment: [{ name: "PATH", value: lookupPath, sensitive: false }], + config: { + ...CodexDriver.defaultConfig(), + binaryPath: fixture.commandName, + homePath: NodePath.join(tempDir, "codex-home"), + }, + }).pipe(Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, metadataSpawner)); + const capabilities = yield* instance.snapshot.resolveMaintenance(); + const latestVersion = yield* resolveLatestProviderVersion(capabilities).pipe( + Effect.provideService( + ProviderVersionCache, + new Map([ + ["@openai/codex", { expiresAt: Number.MAX_SAFE_INTEGER, version: "0.153.4" }], + ]), + ), + ); + expect(probes).toEqual([]); + expect(latestVersion).toBe("0.153.4"); + expect( + createProviderVersionAdvisory({ + driver: CodexDriver.driverKind, + currentVersion: fixture.version, + latestVersion, + maintenanceCapabilities: capabilities, + }), + ).toMatchObject({ + status: fixture.version === "0.153.4" ? "current" : "behind_latest", + currentVersion: fixture.version, + latestVersion: "0.153.4", + canUpdate: fixture.nodeFirst, + }); + if (fixture.nodeFirst) { + expect(capabilities.update).toMatchObject({ + executable: "npm", + args: expect.arrayContaining(["--prefix", npmPrefix, "@openai/codex@latest"]), + }); + } else { + expect(capabilities.update).toBeNull(); + } + }).pipe(Effect.scoped), + { skip: windowsHost }, + ); }); diff --git a/apps/server/src/provider/providerMaintenance.test.ts b/apps/server/src/provider/providerMaintenance.test.ts index 94fc376f227d..2ceaf21996bf 100644 --- a/apps/server/src/provider/providerMaintenance.test.ts +++ b/apps/server/src/provider/providerMaintenance.test.ts @@ -595,25 +595,29 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { }), ); - it.effect.skipIf(!symlinksSupported)( - "upgrades the Homebrew cask that owns the binary and compares against its version", - () => + it.effect.each([ + { directory: "Caskroom", name: "package-tool", kind: "cask" }, + { directory: "Cellar", name: "package-tool", kind: "formula" }, + { directory: "Cellar", name: "package-tool@latest", kind: "formula" }, + ] as const)( + "upgrades the owning Homebrew $kind $name through an executable alias", + (fixture) => Effect.gen(function* () { const tempDir = yield* makeTempDir("t3-homebrew-capabilities"); const brewBinDir = NodePath.join(tempDir, "brew-bin"); const brewPath = NodePath.join(brewBinDir, "brew"); writeExecutable(brewPath); - const caskBinary = NodePath.join( + const ownedBinary = NodePath.join( tempDir, - "Caskroom", - "package-tool", + fixture.directory, + fixture.name, "0.148.0", - "package-tool", + "package-tool-0.148.0", ); - writeExecutable(caskBinary); - const link = NodePath.join(tempDir, "bin", "package-tool"); + writeExecutable(ownedBinary); + const link = NodePath.join(tempDir, "bin", "custom-package-tool"); NodeFS.mkdirSync(NodePath.dirname(link), { recursive: true }); - NodeFS.symlinkSync(caskBinary, link); + NodeFS.symlinkSync(ownedBinary, link); const spawned: Array> = []; const capabilities = yield* resolveProviderMaintenanceCapabilitiesEffect( @@ -630,27 +634,38 @@ it.layer(NodeServices.layer)("providerMaintenance", (it) => { spawned.push([command, ...args]); return args[0] === "--prefix" ? `${tempDir}\n` - : JSON.stringify({ casks: [{ version: "0.148.0,42" }] }); + : JSON.stringify( + fixture.kind === "cask" + ? { casks: [{ version: "0.148.0,42" }] } + : { formulae: [{ versions: { stable: "0.148.0" } }] }, + ); }), ), ); expect(spawned).toEqual([ [brewPath, "--prefix"], - [brewPath, "info", "--json=v2", "package-tool"], + [brewPath, "info", "--json=v2", fixture.name], ]); expect(capabilities).toEqual({ provider: driver("packageTool"), packageName: "@example/package-tool", latestVersion: "0.148.0", update: { - command: "brew upgrade --cask package-tool", + command: + fixture.kind === "cask" + ? `brew upgrade --cask ${fixture.name}` + : `brew upgrade ${fixture.name}`, executable: brewPath, - args: ["upgrade", "--cask", "package-tool"], + args: + fixture.kind === "cask" + ? ["upgrade", "--cask", fixture.name] + : ["upgrade", fixture.name], lockKey: "homebrew", }, }); }), + { skip: !symlinksSupported }, ); it.effect.skipIf(windowsHost)( diff --git a/apps/server/src/provider/providerMaintenance.ts b/apps/server/src/provider/providerMaintenance.ts index d812f1ab7989..e8ff090a4ec9 100644 --- a/apps/server/src/provider/providerMaintenance.ts +++ b/apps/server/src/provider/providerMaintenance.ts @@ -432,6 +432,10 @@ export const resolvePackageManagedProviderMaintenance = Effect.fn( const homebrew = homebrewOwnershipFromCommandPath(context.realCommandPath); if (homebrew) { + // Mise shims resolve to the version manager, not the provider. + if (homebrew.kind === "formula" && homebrew.name.toLowerCase() === "mise") { + return manual; + } const brewPath = yield* resolveCommandPath("brew", { env: context.env }).pipe( Effect.catchTags({ CommandResolutionError: () => Effect.succeed(null) }), ); From 39802c06117fae0b3da43624b0d54309c5437c72 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 03:42:49 -0700 Subject: [PATCH 117/320] fix(ssh): report remote package installation failures accurately (#10088) --- packages/ssh/src/runnerProcess.test.ts | 140 +++++++++++++++++++++++++ packages/ssh/src/tunnel.ts | 5 +- 2 files changed, 144 insertions(+), 1 deletion(-) diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index 7dda675ee95c..d89ee5582c35 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -165,3 +165,143 @@ if (args.includes("--package")) { ); }, ); + +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote runner install diagnostics", + () => { + const decodeArguments = Schema.decodeUnknownSync( + Schema.fromJsonString(Schema.Array(Schema.String)), + ); + const cases = (["npx", "npm"] as const).flatMap((packageManager) => + ( + [ + "etarget", + "network", + "empty-success", + "success", + "failed-with-path", + "existing-cli", + "node-override", + ] as const + ).map((mode) => ({ packageManager, mode })), + ); + + it.live.each(cases)("handles $packageManager/$mode", ({ packageManager, mode }) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-runner-install-" }); + const bin = path.join(fixture, "bin"); + const cliPath = path.join(fixture, "installed cli.mjs"); + const callsPath = path.join(fixture, "installer-calls.jsonl"); + const packageSpec = "t3@0.0.39-nightly.20260905.1286"; + const args = ["serve", "a path with spaces"]; + yield* fs.makeDirectory(bin); + yield* fs.symlink(process.execPath, path.join(bin, "node")); + yield* fs.writeFileString( + cliPath, + `#!/usr/bin/env node +process.stdout.write(JSON.stringify(process.argv.slice(2)) + "\\n"); +`, + ); + yield* fs.chmod(cliPath, 0o700); + yield* fs.writeFileString(callsPath, ""); + yield* fs.writeFileString( + path.join(bin, packageManager), + `#!/usr/bin/env node +const fs = require("node:fs"); +fs.appendFileSync(process.env.T3_TEST_CALLS, JSON.stringify(process.argv.slice(2)) + "\\n"); +const mode = process.env.T3_TEST_MODE; +if (mode === "success" || mode === "failed-with-path") { + process.stdout.write(process.env.T3_TEST_CLI + "\\n"); +} +if (mode === "etarget" || mode === "failed-with-path") { + process.stderr.write("npm error code ETARGET\\nnpm error notarget No matching version found.\\n"); + process.exitCode = 42; +} else if (mode === "network") { + process.stderr.write("npm error code ENETUNREACH\\n"); + process.exitCode = 43; +} +`, + ); + yield* fs.chmod(path.join(bin, packageManager), 0o700); + if (mode === "existing-cli") yield* fs.symlink(cliPath, path.join(bin, "t3")); + + const child = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s", "--", ...args], { + cwd: fixture, + extendEnv: false, + env: { + PATH: bin, + T3_TEST_MODE: mode, + T3_TEST_CLI: cliPath, + T3_TEST_CALLS: callsPath, + }, + stdin: Stream.make( + new TextEncoder().encode( + buildRemoteT3RunnerScript({ + packageSpec, + ...(mode === "node-override" ? { nodeScriptPath: cliPath } : {}), + }), + ), + ), + }), + ); + const { stdout, stderr, exitCode } = yield* Effect.all( + { + stdout: child.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: child.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: child.exitCode, + }, + { concurrency: "unbounded" }, + ); + const installFailed = + mode === "etarget" || mode === "network" || mode === "failed-with-path"; + const missingExecutable = mode === "empty-success"; + assert.equal(exitCode, installFailed || missingExecutable ? 1 : 0); + if (installFailed || missingExecutable) { + assert.equal(stdout, ""); + } else { + assert.deepEqual(decodeArguments(stdout), args); + } + if (installFailed) { + const npmError = mode === "network" ? "ENETUNREACH" : "ETARGET"; + assert.include(stderr, `npm error code ${npmError}\n`); + assert.include(stderr, `Remote host could not install ${packageSpec}.`); + assert.notInclude(stderr, "Remote host installed"); + assert.notInclude(stderr, "Install a C toolchain"); + } else if (missingExecutable) { + assert.include(stderr, `Remote host installed ${packageSpec}`); + assert.include(stderr, "npm produced no t3 executable"); + assert.include(stderr, "Install a C toolchain"); + } else { + assert.equal(stderr, ""); + } + const expectedCall = [ + ...(packageManager === "npm" ? ["exec"] : []), + "--yes", + "--package", + packageSpec, + "--", + "sh", + "-c", + "command -v t3", + ]; + const usesInstaller = mode !== "existing-cli" && mode !== "node-override"; + const calls = yield* fs.readFileString(callsPath); + if (usesInstaller) { + assert.deepEqual( + calls + .trim() + .split("\n") + .map((line) => decodeArguments(line)), + [expectedCall], + ); + } else { + assert.equal(calls, ""); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index 04dbce65af60..409fd5c7688c 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -433,7 +433,10 @@ fi # never becomes ready. Resolve the CLI once up front so that install failure is # reported here, with npm's own output on stderr. require_installed_t3_cli() { - T3_CLI_PATH="$("$@" -- sh -c 'command -v t3' || true)" + if ! T3_CLI_PATH="$("$@" -- sh -c 'command -v t3')"; then + printf 'Remote host could not install %s. See npm output above for the cause.\\n' @@T3_PACKAGE_SPEC@@ >&2 + return 1 + fi if [ -n "$T3_CLI_PATH" ]; then return 0 fi From bd7f7ea0931127c26cfd283c9c2e98e0a425a06b Mon Sep 17 00:00:00 2001 From: m-de-graaff Date: Sat, 5 Sep 2026 13:20:48 +0200 Subject: [PATCH 118/320] fix(web): keep bulk thread deletion going after failures (#4615) Co-authored-by: Claude Opus 5 (1M context) Co-authored-by: Yash Singh --- apps/web/src/components/LegacySidebar.tsx | 42 ++++++++----- apps/web/src/components/Sidebar.tsx | 43 ++++++++----- apps/web/src/hooks/useThreadActions.test.ts | 37 ++++++++++- apps/web/src/hooks/useThreadActions.ts | 68 +++++++++++---------- apps/web/src/threadSelectionStore.test.ts | 58 +++++++++++++++++- apps/web/src/threadSelectionStore.ts | 9 +++ 6 files changed, 191 insertions(+), 66 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 650ad12bebcb..a3a07839f837 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -171,7 +171,10 @@ import { SidebarMenuSubItem, useSidebar, } from "./ui/sidebar"; -import { useThreadSelectionStore } from "../threadSelectionStore"; +import { + getThreadKeysToDeselectAfterDelete, + useThreadSelectionStore, +} from "../threadSelectionStore"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, @@ -1910,26 +1913,35 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!confirmed) return; } - const deletedThreadKeys = new Set(threadKeys); - for (const { threadRef } of selectedThreadEntries) { + // Only discount batch members after their deletions succeed. + const deletedThreadKeys = new Set(); + let firstError: unknown = null; + for (const { threadKey, threadRef } of selectedThreadEntries) { const result = await deleteThread(threadRef, { deletedThreadKeys, }); if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; + if (isAtomCommandInterrupted(result)) break; + firstError ??= squashAtomCommandFailure(result); + continue; } + deletedThreadKeys.add(threadKey); } - removeFromSelection(threadKeys); + if (firstError !== null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } + removeFromSelection( + getThreadKeysToDeselectAfterDelete(threadKeys, deletedThreadKeys, (threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef !== null && readThreadShell(threadRef) !== null; + }), + ); }, [ appSettingsConfirmThreadArchive, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 77b7b0abf0a6..082ccd13f2ed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -25,6 +25,7 @@ import { import { resolveSettledThreadTimestamp } from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { + parseScopedThreadKey, scopeProjectRef, scopeThreadRef, scopedThreadKey, @@ -101,7 +102,10 @@ import { type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; -import { useThreadSelectionStore } from "../threadSelectionStore"; +import { + getThreadKeysToDeselectAfterDelete, + useThreadSelectionStore, +} from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -112,6 +116,7 @@ import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { + readThreadShell, useAllEnvironmentProjectSnapshotsReady, useProjects, useThreadShells, @@ -3021,8 +3026,9 @@ export default function Sidebar() { // right now. Selections can outlive their rows (settled-tail paging, // thread deletion elsewhere) and the menu labels must count only what // the actions will touch. - const threadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys].filter( - (threadKey) => threadByKeyRef.current.has(threadKey), + const selectedThreadKeys = [...useThreadSelectionStore.getState().selectedThreadKeys]; + const threadKeys = selectedThreadKeys.filter((threadKey) => + threadByKeyRef.current.has(threadKey), ); if (threadKeys.length === 0) return; const count = threadKeys.length; @@ -3218,6 +3224,7 @@ export default function Sidebar() { // really gone, or the first delete would treat still-alive batch mates // as deleted and remove a worktree they still point at. const deletedThreadKeys = new Set(); + let firstError: unknown = null; for (const threadKey of threadKeys) { const thread = threadByKeyRef.current.get(threadKey); if (!thread) continue; @@ -3225,21 +3232,27 @@ export default function Sidebar() { deletedThreadKeys, }); if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to delete threads", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - return; + if (isAtomCommandInterrupted(result)) break; + firstError ??= squashAtomCommandFailure(result); + continue; } deletedThreadKeys.add(threadKey); } - removeFromSelection(threadKeys); + if (firstError !== null) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to delete threads", + description: firstError instanceof Error ? firstError.message : "An error occurred.", + }), + ); + } + removeFromSelection( + getThreadKeysToDeselectAfterDelete(selectedThreadKeys, deletedThreadKeys, (threadKey) => { + const threadRef = parseScopedThreadKey(threadKey); + return threadRef !== null && readThreadShell(threadRef) !== null; + }), + ); }, [ attemptSettle, diff --git a/apps/web/src/hooks/useThreadActions.test.ts b/apps/web/src/hooks/useThreadActions.test.ts index e2a8b6d1b4b1..b042f19893da 100644 --- a/apps/web/src/hooks/useThreadActions.test.ts +++ b/apps/web/src/hooks/useThreadActions.test.ts @@ -1,7 +1,40 @@ import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; -import { requestThreadUnpinConfirmation, ThreadArchiveBlockedError } from "./useThreadActions"; +import { + navigateAfterThreadDeletion, + requestThreadUnpinConfirmation, + ThreadArchiveBlockedError, +} from "./useThreadActions"; +import { toastManager } from "../components/ui/toast"; + +describe("navigateAfterThreadDeletion", () => { + afterEach(() => vi.restoreAllMocks()); + + it("reports a rejected navigation without failing the completed deletion", async () => { + const addToast = vi.spyOn(toastManager, "add").mockReturnValue("navigation-error"); + + await expect( + navigateAfterThreadDeletion(() => Promise.reject(new Error("route unavailable"))), + ).resolves.toBeUndefined(); + + expect(addToast).toHaveBeenCalledOnce(); + expect(addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Thread deleted, but navigation failed", + description: "route unavailable", + }), + ); + }); + + it("does not report an error after successful navigation", async () => { + const addToast = vi.spyOn(toastManager, "add"); + + await navigateAfterThreadDeletion(() => Promise.resolve()); + + expect(addToast).not.toHaveBeenCalled(); + }); +}); describe("ThreadArchiveBlockedError", () => { it("keeps the blocked thread context with the fixed message", () => { diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 64915228c779..9b162bc8cce3 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -144,6 +144,21 @@ export async function requestThreadUnpinConfirmation(input: { ); } +/** Report navigation separately so a completed deletion can still finish worktree cleanup. */ +export async function navigateAfterThreadDeletion(navigate: () => Promise) { + const result = await settlePromise(navigate); + if (result._tag === "Failure") { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Thread deleted, but navigation failed", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } +} + export function useThreadActions() { const closeTerminal = useAtomCommand(terminalEnvironment.close); const archiveThreadMutation = useAtomCommand(threadEnvironment.archive, { @@ -383,39 +398,20 @@ export function useThreadActions() { clearTerminalUiState(threadRef); if (shouldNavigateToFallback) { - if (fallbackThreadId) { - const fallbackThread = readThreadShell( - scopeThreadRef(threadRef.environmentId, fallbackThreadId), - ); - if (fallbackThread) { - const navigationResult = await settlePromise(() => - router.navigate({ + const fallbackThread = fallbackThreadId + ? readThreadShell(scopeThreadRef(threadRef.environmentId, fallbackThreadId)) + : null; + await navigateAfterThreadDeletion(() => + fallbackThread + ? router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams( scopeThreadRef(fallbackThread.environmentId, fallbackThread.id), ), replace: true, - }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - } else { - const navigationResult = await settlePromise(() => - router.navigate({ to: "/", replace: true }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - } - } else { - const navigationResult = await settlePromise(() => - router.navigate({ to: "/", replace: true }), - ); - if (navigationResult._tag === "Failure") { - return navigationResult; - } - } + }) + : router.navigate({ to: "/", replace: true }), + ); } if (!shouldDeleteWorktree || !orphanedWorktreePath || !threadProject) { @@ -444,9 +440,10 @@ export function useThreadActions() { ? refreshResult : null; if (cleanupFailure) { + const removalFailed = removeResult._tag === "Failure"; const error = squashAtomCommandFailure(cleanupFailure); - const message = error instanceof Error ? error.message : "Unknown error removing worktree."; - console.error("Failed to remove orphaned worktree after thread deletion", { + const message = error instanceof Error ? error.message : "An error occurred."; + console.error("Worktree cleanup failed after thread deletion", { threadId: threadRef.threadId, projectCwd: threadProject.workspaceRoot, worktreePath: orphanedWorktreePath, @@ -455,11 +452,16 @@ export function useThreadActions() { toastManager.add( stackedThreadToast({ type: "error", - title: "Thread deleted, but worktree removal failed", - description: `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}`, + title: removalFailed + ? "Failed to delete worktree" + : "Worktree deleted, but Git status refresh failed", + description: removalFailed + ? `Could not remove ${displayWorktreePath ?? orphanedWorktreePath}. ${message}` + : message, }), ); - return cleanupFailure; + // The thread was deleted. Cleanup has its own toast; returning its + // failure would make callers incorrectly report a thread deletion error. } return deleteResult; }, diff --git a/apps/web/src/threadSelectionStore.test.ts b/apps/web/src/threadSelectionStore.test.ts index 3bd97b97d40a..e73f4d96864c 100644 --- a/apps/web/src/threadSelectionStore.test.ts +++ b/apps/web/src/threadSelectionStore.test.ts @@ -1,7 +1,10 @@ import { ThreadId } from "@t3tools/contracts"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { useThreadSelectionStore } from "./threadSelectionStore"; +import { + getThreadKeysToDeselectAfterDelete, + useThreadSelectionStore, +} from "./threadSelectionStore"; const THREAD_A = ThreadId.make("thread-a"); const THREAD_B = ThreadId.make("thread-b"); @@ -16,6 +19,59 @@ describe("threadSelectionStore", () => { useThreadSelectionStore.getState().clearSelection(); }); + describe("bulk deletion cleanup", () => { + it("clears missing selection rows and completed deletions while retaining a failed thread", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.toggleThread(THREAD_C); + const selected = [...useThreadSelectionStore.getState().selectedThreadKeys]; + // A deleted successfully but its shell has not refreshed; B failed; + // C was deleted elsewhere and never entered this client's delete loop. + const existingThreads = new Set([THREAD_A, THREAD_B]); + store.removeFromSelection( + getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), (key) => + existingThreads.has(ThreadId.make(key)), + ), + ); + + expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([THREAD_B]); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); + }); + + it("exits selection mode when the last selected thread disappeared elsewhere", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.removeFromSelection( + getThreadKeysToDeselectAfterDelete([THREAD_A], new Set(), () => false), + ); + + expect(useThreadSelectionStore.getState().hasSelection()).toBe(false); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBeNull(); + }); + + it("keeps unprocessed and hidden live threads and selections added while deletion was pending", () => { + const store = useThreadSelectionStore.getState(); + store.toggleThread(THREAD_A); + store.toggleThread(THREAD_B); + store.toggleThread(THREAD_C); + const selected = [...useThreadSelectionStore.getState().selectedThreadKeys]; + store.toggleThread(THREAD_D); + // Only A completed before interruption. B is unprocessed; C still + // has a shell even though its row is outside the rendered page. + store.removeFromSelection( + getThreadKeysToDeselectAfterDelete(selected, new Set([THREAD_A]), () => true), + ); + + expect([...useThreadSelectionStore.getState().selectedThreadKeys]).toEqual([ + THREAD_B, + THREAD_C, + THREAD_D, + ]); + expect(useThreadSelectionStore.getState().anchorThreadKey).toBe(THREAD_D); + }); + }); + describe("toggleThread", () => { it("adds a thread to empty selection", () => { useThreadSelectionStore.getState().toggleThread(THREAD_A); diff --git a/apps/web/src/threadSelectionStore.ts b/apps/web/src/threadSelectionStore.ts index 2b4022a68fb6..fd7f9366eba8 100644 --- a/apps/web/src/threadSelectionStore.ts +++ b/apps/web/src/threadSelectionStore.ts @@ -34,6 +34,15 @@ interface ThreadSelectionStore extends ThreadSelectionState { const EMPTY_SET = new Set(); +/** Clear completed deletions and missing threads, retaining failed or unprocessed threads. */ +export function getThreadKeysToDeselectAfterDelete( + selectedThreadKeys: readonly string[], + deletedThreadKeys: ReadonlySet, + hasThread: (threadKey: string) => boolean, +): string[] { + return selectedThreadKeys.filter((key) => deletedThreadKeys.has(key) || !hasThread(key)); +} + export const useThreadSelectionStore = create((set, get) => ({ selectedThreadKeys: EMPTY_SET, anchorThreadKey: null, From be7796d867a1e66524f19e6cfee0109c3ab447f0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 04:22:10 -0700 Subject: [PATCH 119/320] fix(web): scale agent spawn rows with interface font (#10092) --- apps/web/src/components/chat/MessagesTimeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 6c6901a08881..4854121b5a3e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -3069,7 +3069,7 @@ const AgentSpawnCtaRow = memo(function AgentSpawnCtaRow(props: { workEntry: Time + } + /> + Copy update command + + ) : ( + + + + ) ) : null} @@ -637,7 +663,7 @@ export function ProviderInstanceCard({ - +
Date: Sat, 5 Sep 2026 18:57:25 -0400 Subject: [PATCH 150/320] fix(web): align provider header action sizes and spacing (#9890) --- .../src/components/settings/ProviderInstanceCard.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index 76bd595b2135..b3faa7b0509f 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -677,7 +677,7 @@ export function ProviderInstanceCard({ } const editorHeaderAction = ( -
+
{driverOption?.badgeLabel ? ( {driverOption.badgeLabel} @@ -695,7 +695,7 @@ export function ProviderInstanceCard({ render={ } /> @@ -784,14 +784,14 @@ export function ProviderInstanceCard({ {onDelete ? ( ) : null} From 3fb8942a427d564cfee4724ad6e40eb9c8881aea Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:42 -0700 Subject: [PATCH 151/320] fix(mobile): restore live tool shimmer and add a Thinking row (#10173) Co-authored-by: Claude Fable 5 --- apps/mobile/src/components/AppSymbol.tsx | 2 + .../src/features/threads/ThreadFeed.tsx | 6 + .../src/features/threads/thread-work-log.tsx | 22 ++++ apps/mobile/src/lib/threadActivity.test.ts | 113 +++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 32 ++++- 5 files changed, 170 insertions(+), 5 deletions(-) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 26fdfd24a4fb..8667ebb72475 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -20,6 +20,7 @@ import IconArrowsMinimize from "@tabler/icons-react-native/IconArrowsMinimize"; import IconBellRinging from "@tabler/icons-react-native/IconBellRinging"; import IconBolt from "@tabler/icons-react-native/IconBolt"; import IconBox from "@tabler/icons-react-native/IconBox"; +import IconBrain from "@tabler/icons-react-native/IconBrain"; import IconCamera from "@tabler/icons-react-native/IconCamera"; import IconChartBar from "@tabler/icons-react-native/IconChartBar"; import IconCheck from "@tabler/icons-react-native/IconCheck"; @@ -109,6 +110,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "bell.badge": IconBellRinging, "bolt.circle": IconBolt, "bolt.horizontal.circle": IconBolt, + brain: IconBrain, camera: IconCamera, "chart.bar.xaxis": IconChartBar, checkmark: IconCheck, diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 03f0b88f5f4c..ee715d1f7d26 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -155,6 +155,7 @@ import { collapsedWorkLogHeight, ThreadDisclosureChevron, ThreadWorkGroupToggle, + ThreadThinkingRow, ThreadWorkLog, THREAD_DISCLOSURE_TRANSITION_MS, WORK_GROUP_TOGGLE_HEIGHT, @@ -1371,6 +1372,10 @@ function renderFeedEntry( ); } + if (entry.type === "thinking") { + return ; + } + if (entry.type === "work-toggle") { return ( ; + readonly iconSubtleColor: ColorValue; +}) { + return ( + + + + ); +} + function ToolActivityIconView(props: { readonly environmentId: EnvironmentId; readonly icon?: ToolActivityIcon; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index df3dd3197d0d..8ab726226bdc 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1566,7 +1566,8 @@ describe("buildThreadFeed", () => { summaryToolIcon: "browser", hasFailure, live: true, - shimmer: false, + // A successful trailing call keeps shining; a failure hands off to "Thinking". + shimmer: !hasFailure, }, { type: "activity-group", @@ -1580,6 +1581,7 @@ describe("buildThreadFeed", () => { }, ], }, + ...(hasFailure ? [{ type: "thinking", turnId }] : []), ]); const terminalGroup = terminalRows[1]; if (terminalGroup?.type !== "activity-group") return; @@ -2128,7 +2130,7 @@ describe("buildThreadFeed", () => { ( [ { lifecycleStatus: "inProgress", summary: "Running pnpm", shimmer: true }, - { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: false }, + { lifecycleStatus: "completed", summary: "Running pnpm", shimmer: true }, { lifecycleStatus: "failed", summary: "Failed pnpm", shimmer: false }, { lifecycleStatus: "declined", summary: "Declined pnpm", shimmer: false }, { lifecycleStatus: "stopped", summary: "Stopped pnpm", shimmer: false }, @@ -2228,8 +2230,12 @@ describe("buildThreadFeed", () => { shimmer, }); expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + // Exactly one live activity: the shimmering call, or "Thinking" once it fails. + expect(rows.filter((entry) => entry.type === "thinking")).toHaveLength(shimmer ? 0 : 1); + expect(rows.at(-1)?.type).toBe(shimmer ? "work-toggle" : "thinking"); const stoppedRows = deriveThreadFeedPresentation(feed, latestTurn, new Set()); + expect(stoppedRows.some((entry) => entry.type === "thinking")).toBe(false); expect(stoppedRows.filter((entry) => entry.type === "work-toggle")).toMatchObject([ { live: false, shimmer: false }, { @@ -2253,6 +2259,109 @@ describe("buildThreadFeed", () => { }, ); + it("shows one Thinking row while a turn works without live tool activity", () => { + const turnId = TurnId.make("turn-thinking"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-thinking"), + projectId: ProjectId.make("project-1"), + title: "Thinking", + latestTurn, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "hello", + turnId, + streaming: false, + createdAt: "2026-04-01T00:00:00.000Z", + updatedAt: "2026-04-01T00:00:00.000Z", + }, + ], + }), + ); + + const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); + expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); + expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId }); + // The row identity is stable across re-derivations so the list can reuse it. + expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( + rows[1], + ); + // Idle threads show no live activity. + expect( + deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), null).map( + (entry) => entry.type, + ), + ).toEqual(["message"]); + }); + + it("hands a settled tool run off to Thinking once assistant text streams after it", () => { + const turnId = TurnId.make("turn-streaming-tail"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-streaming-tail"), + projectId: ProjectId.make("project-1"), + title: "Streaming tail", + latestTurn, + messages: [ + { + id: MessageId.make("assistant-1"), + role: "assistant", + text: "Here is what I found", + turnId, + streaming: true, + createdAt: "2026-04-01T00:00:05.000Z", + updatedAt: "2026-04-01T00:00:06.000Z", + }, + ], + activities: [ + makeActivity({ + id: EventId.make("read-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Read file", + createdAt: "2026-04-01T00:00:02.000Z", + turnId, + payload: { + itemType: "file_read", + toolCallId: "read-1", + title: "Read file", + status: "completed", + detail: "src/index.ts", + }, + }), + ], + }), + ); + + const rows = deriveThreadFeedPresentation( + feed, + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + expect(rows.map((entry) => entry.type)).toEqual(["work-toggle", "message", "thinking"]); + expect(rows[0]).toMatchObject({ live: false, shimmer: false }); + }); + it("preserves serialized shell wrappers with non-matching boundary quotes", () => { const turnId = TurnId.make("turn-serialized-shell-wrapper"); const command = diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index ef75fce56af4..8cb657f858f4 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -169,6 +169,12 @@ export type ThreadFeedEntry = readonly turnId: TurnId; readonly label: string; readonly expanded: boolean; + } + | { + readonly type: "thinking"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; }; export type ThreadFeedLatestTurn = Pick< @@ -201,6 +207,7 @@ const turnFoldRowsCache = new WeakMap< ThreadFeedEntry, Extract >(); +let cachedThinkingRow: Extract | null = null; export function isContextCompactionActivityGroup( entry: Extract, @@ -1510,7 +1517,8 @@ export function deriveThreadFeedPresentation( activeWorkStartedAt: string | null = null, ): ThreadFeedEntry[] { const sourceFeed = feed.filter( - (entry) => entry.type !== "turn-fold" && entry.type !== "work-toggle", + (entry) => + entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", ); const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), @@ -1570,12 +1578,27 @@ export function deriveThreadFeedPresentation( ); } } + // A working turn always shows one live activity. When no tool row is + // shimmering (no tools yet, or the latest failed), that row is "Thinking". + if ( + activeWorkStartedAt !== null && + !result.some((row) => row.type === "work-toggle" && row.shimmer) + ) { + result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); + } return result; } +function thinkingRow(createdAt: string, turnId: TurnId | null) { + if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { + cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId }; + } + return cachedThinkingRow; +} + function appendPresentedFeedEntry( result: ThreadFeedEntry[], - entry: Exclude, + entry: Exclude, expandedWorkGroupIds: ReadonlySet, unsettledTurnId: TurnId | null, isWorking: boolean, @@ -1696,6 +1719,9 @@ function appendToolGroupRows( const active = latestActiveActivity !== undefined; const live = activeTail || active; const latestActivity = latestActiveActivity ?? activities.at(-1)!; + // Like web, the trailing run keeps shining after its latest call succeeds; + // only a failed, declined, or stopped call hands the live slot to "Thinking". + const shimmer = active || (activeTail && latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, live) @@ -1751,7 +1777,7 @@ function appendToolGroupRows( ...(summaryToolIcon ? { summaryToolIcon } : {}), hasFailure: activities.findLast((activity) => activity.toolLike)?.status === "failure", live, - shimmer: active, + shimmer, }); if (!expanded) { return; From 1cb49c3df2e0fb4bc2c6e88b0f102c1f9eb8cee5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:43 -0700 Subject: [PATCH 152/320] fix(mobile): even out the working pill's spacing (#10209) Co-authored-by: Claude Fable 5 --- .../features/threads/floating-working-control.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/mobile/src/features/threads/floating-working-control.tsx b/apps/mobile/src/features/threads/floating-working-control.tsx index cac2686ab218..a429044fccdd 100644 --- a/apps/mobile/src/features/threads/floating-working-control.tsx +++ b/apps/mobile/src/features/threads/floating-working-control.tsx @@ -18,8 +18,12 @@ import { SymbolView } from "../../components/AppSymbol"; import { ControlPill } from "../../components/ControlPill"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; -const CONTROL_HEIGHT = 44; -const CONTROL_COMPOSER_GAP = 8; +const CONTROL_HEIGHT = 38.5; // h-11 with the mobile 14px rem +// The collapsed composer capsule starts 6 below its overlay's top edge, so +// the pill sits at (gap - 6) above the overlay to leave the same gap to the +// capsule as the feed's end inset leaves between it and the last row. +const CONTROL_GAP = 8; +const COMPOSER_CAPSULE_INSET = 6; const GLASS_MERGE_SPACING = 12; const CONTROL_ENTERING = FadeIn.duration(180).reduceMotion(ReduceMotion.System); const CONTROL_EXITING = FadeOut.duration(120).reduceMotion(ReduceMotion.System); @@ -40,7 +44,8 @@ const UniwindGlassContainer = withUniwind(GlassContainer, { }); const AnimatedGlassView = Animated.createAnimatedComponent(UniwindGlassView); -export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_HEIGHT + CONTROL_COMPOSER_GAP; +const CONTROL_OVERLAY_OFFSET = CONTROL_HEIGHT + CONTROL_GAP - COMPOSER_CAPSULE_INSET; +export const FLOATING_WORKING_CONTROL_COVERAGE = CONTROL_OVERLAY_OFFSET + CONTROL_GAP; /** * What the floating pill says. Syncing and working share one element so the @@ -81,7 +86,7 @@ export function FloatingWorkingControl(props: { From 7eda989d38a30d5e35c9efbd946aae3d6f9c065a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:43 -0700 Subject: [PATCH 153/320] fix(mobile): only make work rows expandable when the body adds something (#10210) Co-authored-by: Claude Fable 5 --- .../src/features/threads/thread-work-log.tsx | 20 +---- apps/mobile/src/lib/threadActivity.test.ts | 90 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 46 ++++++++-- 3 files changed, 130 insertions(+), 26 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 57d9a5d91fec..0b22b35cfce5 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,7 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import type { ThreadFeedActivity } from "../../lib/threadActivity"; +import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -307,21 +307,6 @@ export function ShimmeringWorkContent(props: { ); } -function stripShellWrapper(value: string): string { - const trimmed = value.trim(); - const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); - return (match?.[1] ?? trimmed).trim(); -} - -function compactActivityDetail(detail: string | null): string | null { - if (!detail) { - return null; - } - - const cleaned = stripShellWrapper(detail).replace(/\s+/g, " ").trim(); - return cleaned.length > 0 ? cleaned : null; -} - function workRowSymbolName(icon: ThreadFeedActivity["icon"]): AppSymbolName { switch (icon) { case "agent": @@ -697,8 +682,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( const fullDetail = expanded ? row.getFullDetail() : null; const viewedImagePath = workEntryViewedImagePath(row.workEntry); const toolPresentation = resolveWorkEntryToolPresentation(row.workEntry); - const previewText = - toolPresentation?.displayName ?? compactActivityDetail(row.detail) ?? row.summary; + const previewText = workEntryRowLabel(row.workEntry); const displayText = !toolPresentation && expanded && row.workEntry.command?.trim() ? "Command" : previewText; const iconIsDestructive = row.icon === "alert" || row.icon === "warning"; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 8ab726226bdc..c263a9f2fe93 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -21,6 +21,7 @@ import { isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, togglePendingUserInputOptionSelection, + workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, } from "./threadActivity"; @@ -702,6 +703,95 @@ describe("buildThreadFeed", () => { const [row] = group.activities; expect(row?.workEntry.detail).toBe(command); expect(row?.getFullDetail()).toBe(`${command}\n\n${command}`); + // Opening it would only repeat the command the row already shows. + expect(row?.canExpand).toBe(false); + }); + + it.each([ + { + name: "a task summary that is its own detail", + activity: { + kind: "task.completed" as const, + tone: "info" as const, + summary: "Task completed", + payload: { + taskId: "bh2p996o4", + status: "completed", + title: "Check CI on the new head", + summary: "Check CI on the new head", + detail: "Check CI on the new head", + agentKind: "background", + taskType: "local_bash", + }, + }, + label: "Check CI on the new head", + canExpand: false, + }, + { + name: "a runtime warning with only its message", + activity: { + kind: "runtime.warning" as const, + tone: "info" as const, + summary: "Bash is unusable in this environment", + payload: { detail: "Bash is unusable in this environment" }, + }, + label: "Bash is unusable in this environment", + canExpand: false, + }, + { + name: "a multi-line task report", + activity: { + kind: "task.completed" as const, + tone: "info" as const, + summary: "Task completed", + payload: { + taskId: "ae3f85a", + status: "completed", + title: "Audit the PR", + detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.", + agentKind: "agent", + taskType: "local_agent", + }, + }, + label: "**Tooling note:** Bash is unusable. # Audit No blockers.", + canExpand: true, + }, + { + name: "a command whose output differs from the command", + activity: { + kind: "tool.completed" as const, + tone: "tool" as const, + summary: "Command run", + payload: { + itemType: "command_execution", + title: "Command run", + detail: "Bash: printf hello", + data: { toolName: "Bash", command: "printf hello", rawOutput: { content: "hello" } }, + }, + }, + label: "printf hello", + canExpand: true, + }, + ])("only lets $name expand when the body adds something: $canExpand", (input) => { + const thread = makeThread({ + id: ThreadId.make("thread-expand-rule"), + projectId: ProjectId.make("project-1"), + title: "Expand rule", + activities: [ + makeActivity({ + id: EventId.make("expand-rule"), + createdAt: "2026-09-01T00:00:00.000Z", + ...input.activity, + }), + ], + }); + + const [group] = buildThreadFeed(thread); + expect(group?.type).toBe("activity-group"); + if (group?.type !== "activity-group") return; + const [row] = group.activities; + expect(workEntryRowLabel(row!.workEntry)).toBe(input.label); + expect(row?.canExpand).toBe(input.canExpand); }); it("drops a truncated Claude echo of a long command", () => { diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8cb657f858f4..fa01142d046b 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -878,13 +878,43 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { return blocks.length > 0 ? blocks.join("\n\n") : null; } -function workEntryHasExpandedBody(entry: WorkLogEntry): boolean { - return ( - (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) || - Boolean((entry.rawCommand ?? entry.command)?.trim()) || - Boolean(entry.detail?.trim()) || - (entry.changedFiles?.some((path) => path.trim().length > 0) ?? false) - ); +/** + * A row only opens when its body says more than its collapsed line. A row + * whose only detail is the single-line text it already shows (a runtime + * warning, a task summary, a short command) has nothing to reveal. + * Multi-line text still expands: the collapsed row truncates it to one line. + * Cheap field checks come first so large tool payloads are not serialized + * for every row (see the deferred-expansion test). + */ +function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { + if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; + if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; + const parts = [entry.rawCommand ?? entry.command, entry.detail] + .map((value) => value?.trim()) + .filter((value): value is string => Boolean(value)); + if (parts.length === 0) return false; + if (parts.length > 1 && new Set(parts).size > 1) return true; + const only = parts[0]!; + return only.includes("\n") || collapseWhitespace(only) !== collapseWhitespace(collapsedText); +} + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, " ").trim(); +} + +function stripShellWrapper(value: string): string { + const trimmed = value.trim(); + const match = trimmed.match(/^\/bin\/zsh -lc ['"]?([\s\S]*?)['"]?$/); + return (match?.[1] ?? trimmed).trim(); +} + +/** The one-line text a collapsed work row shows. */ +export function workEntryRowLabel(entry: WorkLogEntry): string { + const presentation = resolveWorkEntryToolPresentation(entry); + if (presentation) return presentation.displayName; + const preview = workEntryPreview(entry); + const compactPreview = preview === null ? null : collapseWhitespace(stripShellWrapper(preview)); + return compactPreview || workEntryHeading(entry); } function memoizeValue(build: () => T): () => T { @@ -2098,7 +2128,7 @@ function toThreadFeedActivityEntry( turnId: entry.turnId, summary, detail, - canExpand: workEntryHasExpandedBody(entry), + canExpand: workEntryHasExpandedBody(entry, workEntryRowLabel(entry)), getFullDetail, getCopyText, icon: workEntryIcon(entry), From 579a77588684fc4012e28754cc4f662aa24730c7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:44 -0700 Subject: [PATCH 154/320] fix(mobile): fold subagent lifecycle rows into one batch per spawn (#10211) Co-authored-by: Claude Fable 5 --- apps/mobile/src/lib/threadActivity.test.ts | 229 ++++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 254 ++++++++++++++++++--- 2 files changed, 450 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index c263a9f2fe93..6131dc9a1b31 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -745,12 +745,12 @@ describe("buildThreadFeed", () => { tone: "info" as const, summary: "Task completed", payload: { - taskId: "ae3f85a", + taskId: "bpxcizf97", status: "completed", title: "Audit the PR", detail: "**Tooling note:** Bash is unusable.\n\n# Audit\n\nNo blockers.", - agentKind: "agent", - taskType: "local_agent", + agentKind: "background", + taskType: "local_bash", }, }, label: "**Tooling note:** Bash is unusable. # Audit No blockers.", @@ -2745,16 +2745,104 @@ describe("quiet timeline: nested agents", () => { const rows = buildThreadFeed(thread).flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + // The agent folds into its spawn batch, which stays live after a resume. expect(rows).toMatchObject([ { lifecycleStatus: "inProgress", - summary: "Reviewer", - workEntry: { label: resumeKind === "task.progress" ? "Review resumed" : "Review" }, + summary: "Kicked off 1 subagent · 1 working", + workEntry: { agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] } }, }, ]); }, ); + it("folds a turn's direct spawns into one batch row that tracks their states", () => { + const turnId = TurnId.make("turn-spawn"); + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed" | "task.updated", + taskId: string, + status: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `${taskId} ${status}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + status, + ...extra, + }, + }); + const shell = makeActivity({ + id: EventId.make("shell-1"), + kind: "task.completed", + summary: "Task completed", + createdAt: "2026-04-01T00:00:05.000Z", + turnId, + payload: { + taskId: "sh-1", + agentKind: "background", + taskType: "local_bash", + status: "completed", + title: "Run tests", + detail: "Run tests", + }, + }); + const activities = [ + agent("a-start", "task.started", "a", "running", 1), + agent("b-start", "task.started", "b", "running", 2), + agent("a-progress", "task.progress", "a", "running", 3, { detail: "Reading files" }), + shell, + agent("b-progress", "task.progress", "b", "running", 6, { detail: "Grepping" }), + ]; + const rowsFor = (extraActivities: ReadonlyArray>) => + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn"), + projectId: ProjectId.make("project-1"), + title: "Spawns", + activities: [...activities, ...extraActivities], + }), + ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + + const running = rowsFor([]); + expect(running.map((row) => [row.id, row.summary])).toEqual([ + ["a-progress", "Kicked off 2 subagents · 2 working"], + ["shell-1", "Run tests"], + ]); + expect(running[0]).toMatchObject({ + lifecycleStatus: "inProgress", + workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, + }); + + const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); + expect(oneDone[0]).toMatchObject({ + id: "a-progress", + summary: "Kicked off 2 subagents · 1 working", + lifecycleStatus: "inProgress", + }); + + const allDone = rowsFor([ + agent("a-done", "task.completed", "a", "completed", 7), + agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), + ]); + expect(allDone[0]).toMatchObject({ + id: "a-progress", + summary: "Ran 2 subagents · 1 failed", + lifecycleStatus: "failed", + status: "failure", + }); + expect(allDone).toHaveLength(2); + }); + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( "replaces Antigravity batch progress with %s", (status) => { @@ -2802,19 +2890,146 @@ describe("quiet timeline: nested agents", () => { const rows = buildThreadFeed(thread).flatMap((entry) => entry.type === "activity-group" ? entry.activities : [], ); + // Turn-less batches never share a spawn group, so each keeps its own row. expect(rows).toHaveLength(2); expect(rows[0]).toMatchObject({ lifecycleStatus: status === "failed" ? "failed" : "stopped", - detail, - workEntry: { taskId: "trajectory:4", toolTitle: "Antigravity subagent batch" }, + summary: `Ran 1 subagent · ${status === "failed" ? "1 failed" : "1 stopped"}`, + workEntry: { + taskId: "trajectory:4", + toolTitle: "Antigravity subagent batch", + agentSpawn: { agents: [{ detail }] }, + }, }); + expect(rows[0]?.getFullDetail()).toContain(detail); expect(rows[1]).toMatchObject({ lifecycleStatus: "inProgress", + summary: "Kicked off 1 subagent · 1 working", workEntry: { taskId: "trajectory:5" }, }); }, ); + it("folds bypassed Claude workflow members into the coordinator's batch and settles them with it", () => { + const turnId = TurnId.make("turn-workflow"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const thread = makeThread({ + id: ThreadId.make("thread-workflow"), + projectId: ProjectId.make("project-1"), + title: "Workflow", + activities: [ + makeActivity({ + id: EventId.make("wf-progress"), + kind: "task.progress", + summary: "Workflow running", + createdAt: at(1), + turnId, + payload: { + taskId: "wf-1", + taskType: "local_workflow", + workflowName: "review", + agentKind: "agent", + title: "review", + status: "running", + }, + }), + // Members are synthesized with timelineBypass and never render alone. + ...[0, 1].map((index) => + makeActivity({ + id: EventId.make(`member-${index}`), + kind: "task.progress", + summary: `Agent ${index}`, + createdAt: at(2 + index), + turnId, + payload: { + taskId: `wf-1:wf:${index}`, + agentKind: "agent", + title: `Reviewer ${index}`, + description: `Reviewer ${index}`, + status: index === 0 ? "completed" : "running", + parentAgentId: "wf-1", + timelineBypass: true, + }, + }), + ), + makeActivity({ + id: EventId.make("wf-done"), + kind: "task.completed", + summary: "Task completed", + createdAt: at(10), + turnId, + payload: { + taskId: "wf-1", + taskType: "local_workflow", + workflowName: "review", + agentKind: "agent", + status: "completed", + title: "review", + }, + }), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(1); + // The member that never reported its own end settles with the coordinator. + expect(rows[0]).toMatchObject({ + id: "wf-progress", + summary: "Ran 2 subagents · completed", + lifecycleStatus: "completed", + workEntry: { + agentSpawn: { + workflowId: "wf-1", + agentTaskIds: ["wf-1", "wf-1:wf:0", "wf-1:wf:1"], + }, + }, + }); + expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); + }); + + it("treats a Codex child's idle turn end as a finished batch member", () => { + const turnId = TurnId.make("turn-codex"); + const child = ( + id: string, + kind: "task.started" | "task.updated", + status: string, + seconds: number, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `${status}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId: "child-1", + agentKind: "agent", + title: "math_one", + status, + timelineBypass: true, + }, + }); + const thread = makeThread({ + id: ThreadId.make("thread-codex"), + projectId: ProjectId.make("project-1"), + title: "Codex children", + activities: [ + child("c-start", "task.started", "running", 1), + child("c-running", "task.updated", "running", 2), + child("c-idle", "task.updated", "idle", 5), + ], + }); + const rows = buildThreadFeed(thread).flatMap((entry) => + entry.type === "activity-group" ? entry.activities : [], + ); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + summary: "Ran 1 subagent · completed", + lifecycleStatus: "completed", + }); + }); + it("keeps a nested agent's terminal row but hides its background work", () => { const thread = makeThread({ id: ThreadId.make("thread-nested"), diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fa01142d046b..1d6fb6c0e252 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -110,7 +110,20 @@ export interface WorkLogEntry { toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; toolCallId?: string; - agentSpawn?: boolean; + /** + * One row per workflow run or per-turn batch of direct spawns, like web's + * "Kicked off N subagents" CTA. Mobile has no Agents sheet, so the row + * also carries each agent's terminal state to derive its status label. + */ + agentSpawn?: { + readonly workflowId: string | null; + readonly agentTaskIds: ReadonlyArray; + readonly agents: ReadonlyArray<{ + readonly title: string; + readonly status: WorkLogToolLifecycleStatus | undefined; + readonly detail: string | undefined; + }>; + }; toolData?: unknown; } @@ -119,6 +132,9 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; + isWorkflowCoordinator?: boolean; + /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ + isBackgroundTask?: boolean; } type RawThreadFeedEntry = @@ -388,10 +404,12 @@ function isTerminalTaskUpdate(activity: OrchestrationThreadActivity): boolean { /** * Quiet-timeline guarantee (mirrors web's session-logic): agent-internal - * activity lives in the Agents sheet, not the work log. Terminal rows are - * kept — with no Agents surface on mobile they are the terminal signal - * (a surface that hides rows must keep its own terminal signal). That means - * task.completed and terminal task.updated, including Antigravity cancellation. + * activity lives in the Agents sheet, not the work log. Agent lifecycle rows + * pass even when bypassed or owned by another agent, because they fold into + * their spawn batch rather than rendering on their own; that is how Codex + * children (all bypassed) and Claude workflow members reach the batch row. + * Terminal rows are kept regardless — with no Agents surface on mobile they + * are the terminal signal. */ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean { const payload = @@ -401,20 +419,26 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean if (!payload) { return false; } - const isTerminalTaskRow = activity.kind === "task.completed" || isTerminalTaskUpdate(activity); - if (payload.timelineBypass === true && !isTerminalTaskRow) { - return true; - } - // agentId marks ownership, not "hide me": a NESTED AGENT's terminal row is - // the only signal mobile gets (no Agents sheet), so it stays. Only an - // agent's own background work (stamped "background") is internal — same - // rule as web (review finding: hiding on agentId alone dropped nested - // completions with no replacement UI). + const isTaskRow = + activity.kind === "task.progress" || + activity.kind === "task.updated" || + activity.kind === "task.completed"; const ownedByAgent = typeof payload.agentId === "string" && payload.agentId.trim().length > 0; - if (!ownedByAgent) { - return false; + if (isTaskRow) { + if (!ownedByAgent && payload.timelineBypass !== true) { + return false; + } + // An agent's own shells stay internal; the agents themselves fold into + // their batch. A bypassed batch marker keeps its terminal row. + if (typeof payload.taskId === "string" && payload.agentKind === "agent") { + return false; + } + if (ownedByAgent) { + return true; + } + return !(activity.kind === "task.completed" || isTerminalTaskUpdate(activity)); } - return !(isTerminalTaskRow && payload.agentKind === "agent"); + return payload.timelineBypass === true || ownedByAgent; } function deriveWorkLogEntries( @@ -511,8 +535,16 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (toolCallId) { entry.toolCallId = toolCallId; } - if (isTaskActivity && payload?.agentKind === "agent") { - entry.agentSpawn = true; + if (isTaskActivity && payload) { + if (payload.agentKind !== "agent") { + entry.isBackgroundTask = true; + } + if ( + payload.taskType === "local_workflow" || + (typeof payload.workflowName === "string" && payload.workflowName.length > 0) + ) { + entry.isWorkflowCoordinator = true; + } } const itemType = extractWorkLogItemType(payload); const requestKind = extractWorkLogRequestKind(payload); @@ -579,6 +611,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (!toolLifecycleStatus && activity.kind === "tool.completed") { toolLifecycleStatus = "completed"; } + // A Codex child that finishes its turn reports "idle" (resumable, not + // terminal). For the batch row that is a finished member. + if (!toolLifecycleStatus && isTaskActivity && payload?.status === "idle") { + toolLifecycleStatus = "completed"; + } if (toolLifecycleStatus) { entry.toolLifecycleStatus = toolLifecycleStatus; } @@ -589,13 +626,108 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo return entry; } +/** + * Spawn-group key for a subagent lifecycle row. Workflow members and their + * coordinator share the coordinator's group; direct spawns batch per turn. + * Same keys as web's session-logic so both clients fold the same rows. + */ +function agentSpawnGroupKey(entry: DerivedWorkLogEntry): string { + const taskId = entry.taskId ?? ""; + const workflowSlot = taskId.indexOf(":wf:"); + if (workflowSlot !== -1) return `wf:${taskId.slice(0, workflowSlot)}`; + if (entry.isWorkflowCoordinator) return `wf:${taskId}`; + return entry.turnId ? `direct:${entry.turnId}` : `direct:task:${taskId}`; +} + +/** + * The batch row keeps the group's anchor identity (id, createdAt, turnId, + * label) so it renders where the run launched instead of drifting to the + * newest progress tick, and gains each member's latest lifecycle state. + */ +function agentSpawnRow( + anchor: DerivedWorkLogEntry, + workflowId: string | null, + agentTaskIds: ReadonlyArray, + members: NonNullable["agents"], +): DerivedWorkLogEntry { + // A finished coordinator settles members that never reported their own + // end; Claude stops synthesizing member ticks once the workflow is done. + const coordinator = workflowId === null ? undefined : members[agentTaskIds.indexOf(workflowId)]; + const agents = + coordinator?.status !== undefined && coordinator.status !== "inProgress" + ? members.map((agent) => + agent.status === undefined || agent.status === "inProgress" + ? { ...agent, status: coordinator.status } + : agent, + ) + : members; + const agentSpawn = { workflowId, agentTaskIds, agents }; + // The batch row has no detail of its own: its body lists the members. + const { detail: _detail, ...anchorWithoutDetail } = anchor; + return { + ...anchorWithoutDetail, + // The row's own lifecycle is the batch's: live while any member is, then + // the worst terminal state, so the group summary and shimmer follow it. + toolLifecycleStatus: agentSpawnLifecycleStatus(agents), + agentSpawn, + }; +} + +function agentSpawnMember( + entry: DerivedWorkLogEntry, + previous?: NonNullable["agents"][number], +) { + return { + title: entry.toolTitle ?? previous?.title ?? entry.label, + status: entry.toolLifecycleStatus ?? previous?.status, + detail: entry.detail ?? previous?.detail, + }; +} + +function mergeAgentSpawnEntries( + existing: DerivedWorkLogEntry, + entry: DerivedWorkLogEntry, +): DerivedWorkLogEntry { + const spawn = existing.agentSpawn!; + const taskId = entry.taskId ?? ""; + const memberIndex = spawn.agentTaskIds.indexOf(taskId); + if (memberIndex === -1) { + return agentSpawnRow( + existing, + spawn.workflowId, + [...spawn.agentTaskIds, taskId], + [...spawn.agents, agentSpawnMember(entry)], + ); + } + const agents = spawn.agents.map((agent, index) => + index === memberIndex ? agentSpawnMember(entry, agent) : agent, + ); + return agentSpawnRow(existing, spawn.workflowId, spawn.agentTaskIds, agents); +} + +function agentSpawnLifecycleStatus( + agents: NonNullable["agents"], +): WorkLogToolLifecycleStatus { + const statuses = agents.map((agent) => agent.status); + if (statuses.some((status) => status === undefined || status === "inProgress")) { + return "inProgress"; + } + if (statuses.includes("failed")) return "failed"; + if (statuses.includes("stopped")) return "stopped"; + return "completed"; +} + function collapseDerivedWorkLogEntries( entries: ReadonlyArray, ): DerivedWorkLogEntry[] { const collapsed: DerivedWorkLogEntry[] = []; - // Subagent rows collapse by identity, not adjacency (quiet-timeline - // guarantee; mirrors web's session-logic). + // Task rows collapse by identity, not adjacency (quiet-timeline guarantee; + // mirrors web's session-logic). Background tasks keep one row per taskId; + // agent spawns fold into one row per spawn group, decided at the FIRST row + // seen for a taskId because later rows can arrive under synthetic turns. const taskRowIndex = new Map(); + const spawnRowIndex = new Map(); + const spawnGroupByTaskId = new Map(); const toolLifecycleRowIndex = new Map(); for (const entry of entries) { const isTaskRow = @@ -604,13 +736,32 @@ function collapseDerivedWorkLogEntries( entry.sourceActivityKind === "task.completed" || entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { - const existingIndex = taskRowIndex.get(entry.taskId); + if (entry.isBackgroundTask) { + const existingIndex = taskRowIndex.get(entry.taskId); + if (existingIndex !== undefined) { + collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + continue; + } + taskRowIndex.set(entry.taskId, collapsed.length); + collapsed.push(entry); + continue; + } + const groupKey = spawnGroupByTaskId.get(entry.taskId) ?? agentSpawnGroupKey(entry); + spawnGroupByTaskId.set(entry.taskId, groupKey); + const existingIndex = spawnRowIndex.get(groupKey); if (existingIndex !== undefined) { - collapsed[existingIndex] = mergeDerivedWorkLogEntries(collapsed[existingIndex]!, entry); + collapsed[existingIndex] = mergeAgentSpawnEntries(collapsed[existingIndex]!, entry); continue; } - taskRowIndex.set(entry.taskId, collapsed.length); - collapsed.push(entry); + spawnRowIndex.set(groupKey, collapsed.length); + collapsed.push( + agentSpawnRow( + entry, + groupKey.startsWith("wf:") ? groupKey.slice(3) : null, + [entry.taskId], + [agentSpawnMember(entry)], + ), + ); continue; } const lifecycleKey = toolLifecycleCollapseMapKey(entry); @@ -821,6 +972,16 @@ function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { } function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { + if (entry.agentSpawn) { + switch (entry.toolLifecycleStatus) { + case "failed": + return "failure"; + case "completed": + return "success"; + default: + return "neutral"; + } + } if (!workLogEntryIsToolLike(entry)) { return null; } @@ -834,6 +995,7 @@ function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { } function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { + if (entry.agentSpawn) return "agent"; if ( entry.sourceActivityKind === "user-input.requested" || entry.sourceActivityKind === "user-input.resolved" @@ -860,6 +1022,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { } function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { + if (entry.agentSpawn) return agentSpawnExpandedBody(entry.agentSpawn); const blocks: string[] = []; const appendBlock = (value: string | null | undefined) => { const trimmed = value?.trim(); @@ -887,6 +1050,7 @@ function buildWorkEntryExpandedBody(entry: WorkLogEntry): string | null { * for every row (see the deferred-expansion test). */ function workEntryHasExpandedBody(entry: WorkLogEntry, collapsedText: string): boolean { + if (entry.agentSpawn) return agentSpawnMembers(entry.agentSpawn).length > 0; if (entry.itemType === "mcp_tool_call" && entry.toolData !== undefined) return true; if (entry.changedFiles?.some((path) => path.trim().length > 0)) return true; const parts = [entry.rawCommand ?? entry.command, entry.detail] @@ -910,6 +1074,7 @@ function stripShellWrapper(value: string): string { /** The one-line text a collapsed work row shows. */ export function workEntryRowLabel(entry: WorkLogEntry): string { + if (entry.agentSpawn) return agentSpawnLabel(entry.agentSpawn); const presentation = resolveWorkEntryToolPresentation(entry); if (presentation) return presentation.displayName; const preview = workEntryPreview(entry); @@ -950,7 +1115,44 @@ function capitalizePhrase(value: string): string { return `${trimmed.charAt(0).toUpperCase()}${trimmed.slice(1)}`; } +/** + * Batch label for a spawn row, matching web's CTA wording. Web reads live + * agent state from its Agents panel; mobile has only the lifecycle states + * folded into the row, so "working" means a member has not reported a + * terminal state yet. + */ +export function agentSpawnLabel(spawn: NonNullable): string { + const members = agentSpawnMembers(spawn); + const count = Math.max(members.length, 1); + const subjects = `${count} subagent${count === 1 ? "" : "s"}`; + const working = members.filter( + (agent) => agent.status === undefined || agent.status === "inProgress", + ).length; + const failed = members.filter((agent) => agent.status === "failed").length; + const stopped = members.filter((agent) => agent.status === "stopped").length; + if (working > 0) { + return `Kicked off ${subjects} · ${working} working`; + } + const status = failed > 0 ? `${failed} failed` : stopped > 0 ? `${stopped} stopped` : "completed"; + return `Ran ${subjects} · ${status}`; +} + +/** Workflow coordinators sit in their own batch but are not a member. */ +function agentSpawnMembers(spawn: NonNullable) { + return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); +} + +function agentSpawnExpandedBody(spawn: NonNullable): string | null { + const lines = agentSpawnMembers(spawn).map((agent) => { + const status = + agent.status === undefined || agent.status === "inProgress" ? "working" : agent.status; + return `${agent.title} · ${status}${agent.detail ? `\n ${agent.detail}` : ""}`; + }); + return lines.length > 0 ? lines.join("\n") : null; +} + function workEntryHeading(workEntry: WorkLogEntry): string { + if (workEntry.agentSpawn) return agentSpawnLabel(workEntry.agentSpawn); const presentation = resolveWorkEntryToolPresentation(workEntry); if (presentation) return presentation.displayName; if (!workEntry.toolTitle) { @@ -1706,7 +1908,7 @@ function appendActivityGroupRows( groupableRun = []; }; for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn !== true) { + if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) { groupableRun.push(activity); continue; } From 89cc7434f0a5373f1b9c73ee579480e8f862fa5b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 15:57:44 -0700 Subject: [PATCH 155/320] fix(mobile): stop clipping expanded tool groups (#10212) Co-authored-by: Claude Fable 5 --- .../src/features/threads/ThreadFeed.tsx | 5 + .../src/features/threads/thread-work-log.tsx | 128 +++++++++++------- 2 files changed, 85 insertions(+), 48 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index ee715d1f7d26..d53ae65be062 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1331,6 +1331,7 @@ function renderFeedEntry( readonly renderMarkdownImage: MarkdownImageRenderer; readonly renderViewedImage: MarkdownImageRenderer; readonly iconSubtleColor: string | import("react-native").ColorValue; + readonly screenColor: string; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; readonly reviewCommentColors: ReviewCommentColors; @@ -1597,6 +1598,7 @@ function renderFeedEntry( rowSizing={props.workRowSizing} scrollPositions={props.workGroupScrollPositions} iconSubtleColor={iconSubtleColor} + edgeFadeColor={props.screenColor} themeAppearance={props.themeAppearance} onCopyRow={props.onCopyWorkRow} onToggleRow={props.onToggleWorkRow} @@ -1986,6 +1988,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const theme = useUniwindTheme(); const iconSubtleColor = theme["--color-icon-subtle"]; + const screenColor = theme["--color-screen"]; const userBubbleColor = theme["--color-user-bubble"]; const onMarkdownLinkPress = useCallback( (href: string) => { @@ -2634,6 +2637,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { renderMarkdownImage, renderViewedImage, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, @@ -2656,6 +2660,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { terminalAssistantMessageIds, unsettledTurnId, iconSubtleColor, + screenColor, userBubbleColor, markdownStyles, reviewCommentColors, diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 0b22b35cfce5..13a78b8f1454 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -385,6 +385,8 @@ interface ThreadWorkLogProps { readonly rowSizing: ReturnType; readonly scrollPositions: Map; readonly iconSubtleColor: ColorValue; + /** Feed background, painted as the scroll-edge fade over a long group. */ + readonly edgeFadeColor: string; readonly themeAppearance: "light" | "dark"; readonly onCopyRow: (rowId: string, value: string) => void; readonly onToggleRow: (rowId: string, anchorKey: string) => void; @@ -430,6 +432,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { {props.activities[0]?.groupedToolDetail ? ( ; + readonly edgeFadeColor: string; readonly expandedRows: Readonly>; readonly groupId: string; readonly rowSizing: ReturnType; @@ -485,21 +489,17 @@ function ThreadWorkGroupList(props: { const height = Math.min(contentHeight, WORK_GROUP_MAX_HEIGHT); const scrollOffset = useSharedValue(initialPosition?.scrollOffset ?? 0); const sharedValues = useMemo(() => ({ scrollOffset }), [scrollOffset]); - const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; - const fadeFraction = WORK_GROUP_EDGE_FADE_HEIGHT / height; - // Opaque covers remove each edge fade at the scroll boundary. Scroll offset - // stays on the UI thread; only content-size changes update React state. - const topCoverStyle = useAnimatedStyle(() => ({ - opacity: 1 - Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), + // Each edge fades only while content continues past it. Scroll offset stays + // on the UI thread; only content-size changes update React state. + const topFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min(1, Math.max(0, scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT), })); - const bottomCoverStyle = useAnimatedStyle(() => ({ - opacity: - 1 - - Math.min( - 1, - Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, - ), + const bottomFadeStyle = useAnimatedStyle(() => ({ + opacity: Math.min( + 1, + Math.max(0, contentHeight - height - scrollOffset.value) / WORK_GROUP_EDGE_FADE_HEIGHT, + ), })); const rememberPosition = useCallback(() => { if (!loadedRef.current) return; @@ -525,7 +525,7 @@ function ThreadWorkGroupList(props: { } }, []); const onContentSizeChange = useCallback( - (_width: number, nextHeight: number) => { + (nextHeight: number) => { const previous = previousContent.current; const detailsChanged = previous.expandedRows !== props.expandedRows; const followAppend = @@ -565,6 +565,24 @@ function ThreadWorkGroupList(props: { }, [props.activities, props.expandedRows, scrollOffset, finishPendingAppend, rememberPosition], ); + // The native ScrollView reports its content size a frame or more after + // LegendList has laid the rows out, so a detail toggle rendered the group + // at its old height while the rows below already moved. Read the size + // LegendList computes on the JS thread instead; it settles in the same + // commit as the row measurement that changed it. + const onContentSizeChangeRef = useRef(onContentSizeChange); + useLayoutEffect(() => { + onContentSizeChangeRef.current = onContentSizeChange; + }, [onContentSizeChange]); + const subscribeToContentSize = useCallback((list: LegendListRef | null) => { + listRef.current = list; + if (!list) return; + const unsubscribe = list.getState().listen("totalSize", () => { + onContentSizeChangeRef.current(list.getState().contentLength); + }); + onContentSizeChangeRef.current(list.getState().contentLength); + return unsubscribe; + }, []); const getFixedItemSize = useCallback( (row: ThreadFeedActivity, index: number) => props.expandedRows[row.id] || props.rowSizing.fixedRowHeight === undefined @@ -582,34 +600,9 @@ function ThreadWorkGroupList(props: { ); return ( - - - - - - - - - - - - - - - - } - > + { loadedRef.current = true; @@ -654,12 +646,47 @@ function ThreadWorkGroupList(props: { scrollsToTop={false} bounces={false} keyboardShouldPersistTaps="handled" - // MaskedView bridges through a native host whose absolute-fill bounds - // can lag behind a resize. Keep the list's viewport at the group's - // current height when expanding details or appending calls. - style={[StyleSheet.absoluteFill, { height }]} + style={{ height }} /> - + + + + + + + + ); +} + +/** A screen-colored gradient painted over the list edge that still has content past it. */ +function EdgeFade(props: { readonly color: string; readonly direction: "up" | "down" }) { + const gradientId = `work-group-fade-${useId().replaceAll(":", "")}`; + return ( + + + + + + + + + ); } @@ -670,7 +697,12 @@ function workLogRowKey(row: ThreadFeedActivity): string { const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, - "activities" | "copiedRowId" | "expandedRows" | "rowSizing" | "scrollPositions" + | "activities" + | "copiedRowId" + | "edgeFadeColor" + | "expandedRows" + | "rowSizing" + | "scrollPositions" > & { readonly row: ThreadFeedActivity; readonly copied: boolean; From c2cfe59ac356768deb2a7d3e3461c715aa50a7a1 Mon Sep 17 00:00:00 2001 From: Zortos Date: Sun, 6 Sep 2026 00:59:54 +0200 Subject: [PATCH 156/320] fix(web): defer browser discovery in integrations (#9797) --- .../settings/IntegrationsSettings.test.tsx | 65 +++++++++++++++++++ .../settings/IntegrationsSettings.tsx | 7 +- 2 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 apps/web/src/components/settings/IntegrationsSettings.test.tsx diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx new file mode 100644 index 000000000000..1a8dc7d6aae8 --- /dev/null +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -0,0 +1,65 @@ +import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { act, StrictMode, type ReactNode } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { listBrowserImportSources } = vi.hoisted(() => ({ + listBrowserImportSources: vi.fn().mockResolvedValue([]), +})); + +vi.mock("../preview/previewBridge", () => ({ + previewBridge: { listBrowserImportSources }, +})); +vi.mock("../../env", () => ({ isElectron: true })); +vi.mock("../../state/environments", () => ({ + useEnvironments: () => ({ environments: [], isReady: true }), + usePrimaryEnvironment: () => null, +})); +vi.mock("../../hooks/useSettings", () => ({ + PRIMARY_SETTINGS_UNAVAILABLE_MESSAGE: "Connect to an environment", + useClientSettings: (selector: (settings: typeof DEFAULT_CLIENT_SETTINGS) => unknown) => + selector(DEFAULT_CLIENT_SETTINGS), + useClientSettingsHydrated: () => true, + usePrimarySettingsAvailable: () => true, + usePrimarySettings: () => DEFAULT_UNIFIED_SETTINGS, + useUpdatePrimarySettings: () => vi.fn(), +})); +vi.mock("./settingsLayout", async (importOriginal) => ({ + ...(await importOriginal()), + SettingsPageContainer: ({ children }: { children: ReactNode }) => children, +})); + +import { IntegrationsSettingsPanel } from "./IntegrationsSettings"; + +let renderer: ReactTestRenderer | undefined; + +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + listBrowserImportSources.mockClear(); +}); + +afterEach(async () => { + await act(() => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +async function openSettings() { + await act(() => { + renderer = create( + + + , + ); + }); +} + +describe("Integrations browser discovery", () => { + it("does not scan browser files when entering or revisiting settings", async () => { + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + + await act(() => renderer?.unmount()); + await openSettings(); + expect(listBrowserImportSources).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index a3e96107b29c..e950bab31cbe 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -36,7 +36,7 @@ import { } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; -import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useCallback, useRef, useState, type ReactNode } from "react"; import { ScreenRotationIcon } from "~/browser/ScreenRotationIcon"; import { resolveEnvironmentOptionLabel } from "~/components/BranchToolbar.logic"; @@ -804,11 +804,6 @@ function BrowserProfilesSetting({ disabled }: { readonly disabled: boolean }) { .catch(() => setSources((previous) => previous ?? [])); }, []); - // Loaded once so the first open is instant instead of flashing a spinner. - useEffect(() => { - loadSources(); - }, [loadSources]); - // Runs one import for the wizard. A new profile is registered only once the // import succeeds — the cookies land in its partition first — so a blocked // attempt never leaves an empty profile behind. From 88fc41c1b90ff21a7d30d69e0ebffbc0c3511b9c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 16:13:50 -0700 Subject: [PATCH 157/320] refactor(web): test file cache identity through its public helpers (#10219) --- .../components/files/fileContentRevision.test.ts | 16 ++++------------ .../src/components/files/fileContentRevision.ts | 2 +- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/files/fileContentRevision.test.ts b/apps/web/src/components/files/fileContentRevision.test.ts index e2ec7f9f1cad..4a8bb3d5522b 100644 --- a/apps/web/src/components/files/fileContentRevision.test.ts +++ b/apps/web/src/components/files/fileContentRevision.test.ts @@ -1,19 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; -import { - fileContentRevision, - projectFileCacheKey, - projectFileEditorCacheKey, -} from "./fileContentRevision"; +import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; -describe("fileContentRevision", () => { +describe("file cache identity", () => { it("changes for same-length edits", () => { - expect(fileContentRevision("nodeVersion")).not.toBe(fileContentRevision("nodeVeasdrs")); - }); - - it("keeps identical contents stable", () => { - expect(projectFileCacheKey("/repo", "file.json", "contents")).toBe( - projectFileCacheKey("/repo", "file.json", "contents"), + expect(projectFileCacheKey("/repo", "file.json", "nodeVersion")).not.toBe( + projectFileCacheKey("/repo", "file.json", "nodeVeasdrs"), ); }); diff --git a/apps/web/src/components/files/fileContentRevision.ts b/apps/web/src/components/files/fileContentRevision.ts index e51d464925bd..b4e1698a34dc 100644 --- a/apps/web/src/components/files/fileContentRevision.ts +++ b/apps/web/src/components/files/fileContentRevision.ts @@ -1,4 +1,4 @@ -export function fileContentRevision(contents: string): string { +function fileContentRevision(contents: string): string { let hash = 2_166_136_261; for (let index = 0; index < contents.length; index += 1) { hash ^= contents.charCodeAt(index); From 748fe0f8b68b7951df6797d4c22d2ca01314a3dc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 16:15:32 -0700 Subject: [PATCH 158/320] refactor(web): test favicon fallback through the component (#10220) --- .../preview/PreviewFaviconIcon.test.tsx | 82 +++++++++---------- .../components/preview/PreviewFaviconIcon.tsx | 9 +- 2 files changed, 38 insertions(+), 53 deletions(-) diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx index d950a99b59fc..5ab8552b36fe 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.test.tsx @@ -1,51 +1,43 @@ -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it, vi } from "vite-plus/test"; - -const mocks = vi.hoisted(() => ({ favicon: null as string | null })); - -vi.mock("~/browserFaviconStore", () => ({ - useFaviconForThreadUrl: () => mocks.favicon, -})); - -import { FaviconImage, PreviewFaviconIcon, selectFaviconSource } from "./PreviewFaviconIcon"; - -const threadRef = { - environmentId: EnvironmentId.make("env-1"), - threadId: ThreadId.make("thread-1"), -}; - -describe("preview favicon image", () => { - it("renders a captured source before later fallback sources", () => { - expect( - renderToStaticMarkup( - fallback} - />, - ), - ).toContain('src="data:image/png;base64,AAAA"'); - const captured = "data:image/png;base64,AAAA"; - const google = "https://public.example/icon"; - expect(selectFaviconSource([captured, google], new Set())).toBe(captured); - expect(selectFaviconSource([captured, google], new Set([captured]))).toBe(google); - expect(selectFaviconSource([captured, google], new Set([captured, google]))).toBeNull(); - expect(selectFaviconSource(["data:image/png;base64,BBBB", google], new Set([captured]))).toBe( - "data:image/png;base64,BBBB", +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, expect, it, vi } from "vite-plus/test"; + +vi.mock("~/browserFaviconStore", () => ({ useFaviconForThreadUrl: () => null })); + +import { FaviconImage } from "./PreviewFaviconIcon"; + +let renderer: ReactTestRenderer | undefined; + +afterEach(async () => { + await act(async () => renderer?.unmount()); + vi.unstubAllGlobals(); +}); + +it("falls through failed favicon sources and retries when the source list changes", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + const captured = "data:image/png;base64,AAAA"; + const remote = "https://public.example/icon"; + await act(async () => { + renderer = create( + fallback} />, ); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); - it("uses a stored project icon or falls back to the browser mockup", () => { - mocks.favicon = null; - const html = renderToStaticMarkup( - , - ); - expect(html).not.toContain(", + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findByType("img").props.src).toBe(remote); + + await act(async () => renderer!.root.findByType("img").props.onError()); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findByType("span").children).toEqual(["fallback"]); + + await act(async () => { + renderer!.update( + fallback} + />, ); - expect(faviconHtml).toContain('src="data:image/png;base64,AAAA"'); }); + expect(renderer!.root.findByType("img").props.src).toBe(captured); }); diff --git a/apps/web/src/components/preview/PreviewFaviconIcon.tsx b/apps/web/src/components/preview/PreviewFaviconIcon.tsx index 111facfd82dd..b2e1fee3639e 100644 --- a/apps/web/src/components/preview/PreviewFaviconIcon.tsx +++ b/apps/web/src/components/preview/PreviewFaviconIcon.tsx @@ -6,13 +6,6 @@ import { cn } from "~/lib/utils"; import { BrowserMockup } from "./BrowserMockup"; -export function selectFaviconSource( - sources: ReadonlyArray, - failed: ReadonlySet, -): string | null { - return sources.find((candidate) => !failed.has(candidate)) ?? null; -} - export function FaviconImage(props: { sources: ReadonlyArray; fallback: ReactNode; @@ -35,7 +28,7 @@ function FaviconImageAttempt(props: { className?: string | undefined; }) { const [failed, setFailed] = useState>(() => new Set()); - const source = selectFaviconSource(props.sources, failed); + const source = props.sources.find((candidate) => !failed.has(candidate)); if (!source) return props.fallback; return ( Date: Sun, 6 Sep 2026 00:17:36 +0100 Subject: [PATCH 159/320] feat: show provider usage limits with /usage-limits (#9875) --- apps/mobile/src/connection/runtime.ts | 4 +- .../features/threads/ComposerUsageLimits.tsx | 103 ++++++++ .../features/threads/NewTaskDraftScreen.tsx | 24 ++ .../src/features/threads/ThreadComposer.tsx | 55 +++- .../features/threads/ThreadDetailScreen.tsx | 83 ++++++ .../threads/use-composer-command-menu.ts | 29 ++ .../src/features/usage/UsageLimitsSection.tsx | 42 ++- apps/mobile/src/state/server.ts | 1 + apps/server/src/server.test.ts | 249 +++++++++++++++--- apps/server/src/ws.ts | 164 +++++++----- apps/web/src/components/ChatView.tsx | 156 +++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 16 ++ .../components/chat/ComposerUsageLimits.tsx | 97 +++++++ apps/web/src/components/usage/UsageLimits.tsx | 31 ++- apps/web/src/connection/runtime.ts | 6 +- apps/web/src/state/server.ts | 1 + docs/user/usage.md | 5 + packages/client-runtime/src/rpc/session.ts | 3 + packages/client-runtime/src/state/server.ts | 4 + packages/contracts/src/providerUsageLimits.ts | 21 ++ packages/contracts/src/rpc.ts | 6 + packages/shared/src/usageLimits.test.ts | 197 ++++++++++++++ packages/shared/src/usageLimits.ts | 146 +++++++++- 23 files changed, 1315 insertions(+), 128 deletions(-) create mode 100644 apps/mobile/src/features/threads/ComposerUsageLimits.tsx create mode 100644 apps/web/src/components/chat/ComposerUsageLimits.tsx diff --git a/apps/mobile/src/connection/runtime.ts b/apps/mobile/src/connection/runtime.ts index deee27ef040d..ce478962b8b5 100644 --- a/apps/mobile/src/connection/runtime.ts +++ b/apps/mobile/src/connection/runtime.ts @@ -31,7 +31,9 @@ type ConnectionLayerSource = | typeof mobileBackgroundActivityReporterLayer; const providedClientConnectionLayer = snapshotLoaderLayer.pipe( - Layer.provideMerge(Connection.layerWithOptions({ usageLimitSources: true })), + Layer.provideMerge( + Connection.layerWithOptions({ usageLimitSources: true, usageLimitsCommand: true }), + ), Layer.provideMerge( Layer.mergeAll( runtimeContextLayer, diff --git a/apps/mobile/src/features/threads/ComposerUsageLimits.tsx b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..bcd76722c8e5 --- /dev/null +++ b/apps/mobile/src/features/threads/ComposerUsageLimits.tsx @@ -0,0 +1,103 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { Pressable, ScrollView, useWindowDimensions, View } from "react-native"; + +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { AccountLimits, ResetCredits } from "../usage/UsageLimitsSection"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; + +/** + * The /usage-limits result, docked above the composer. It is the Usage → Limits + * card one size down, so the two read as the same thing. The surface is opaque + * because nothing blurs the feed behind it. + */ +export function ComposerUsageLimits({ + report, + environmentId, + onClose, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; + readonly onClose: () => void; +}) { + const now = Date.parse(report.createdAt); + const { height } = useWindowDimensions(); + const close = ( + + + + ); + return ( + + + {report.accounts.map((account, index) => { + const driverLabel = DRIVER_LABEL[account.driver] ?? String(account.driver); + return ( + + ) : undefined + } + /> + ); + })} + {report.accounts.length === 0 ? ( + // Nothing but notices, so the close control needs a row of its own. + + Usage limits + {close} + + ) : null} + {report.notices.map((notice) => ( + + {notice} + + ))} + + + ); +} diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index e1cc7405bde2..a50895bd33da 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -49,6 +49,7 @@ import { VideoPreviewModal, type VideoPreviewSource } from "../../components/Vid import { ProviderIcon } from "../../components/ProviderIcon"; import { SymbolView } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; +import { hasProviderUsageLimits, isUsageLimitsCommand } from "@t3tools/shared/usageLimits"; import { COMPOSER_LAYOUT_TRANSITION, ComposerSurface } from "./ThreadComposer"; import { ShimmeringWorkContent } from "./thread-work-log"; import { ComposerCommandPopover } from "./ComposerCommandPopover"; @@ -309,6 +310,14 @@ export function NewTaskDraftScreen(props: { const isComposerInteractionLocked = isIncomingShareTransferPending || flow.submitting; // Also guard while a submit is in flight: an Android back press or iOS // Cancel would otherwise abandon the screen while the task still starts. + // T3 owns /usage-limits only where Limits has data for the selected provider. + const offersUsageLimits = + flow.selectedProviderStatus !== null && + hasProviderUsageLimits( + flow.selectedProviderStatus.driver, + selectedEnvironmentServerConfig?.providers ?? [], + selectedEnvironmentServerConfig?.usageLimitSources ?? [], + ); const composerMenu = useComposerCommandMenu({ draftMessage: flow.prompt, ownerKey: flow.draftKey, @@ -320,6 +329,7 @@ export function NewTaskDraftScreen(props: { selectedProviderStatus: flow.selectedProviderStatus, hasThread: false, hasCompactableConversation: false, + offersUsageLimits: offersUsageLimits, enabled: isComposerFocused && !isComposerInteractionLocked, onChangeDraftMessage: flow.setPrompt, onUpdateInteractionMode: flow.planModeEnabled ? flow.setInteractionMode : undefined, @@ -908,6 +918,20 @@ export function NewTaskDraftScreen(props: { ); return; } + // T3's own limits command is answered by the thread composer; a new task would + // send it to the agent. A provider's same-named command, or a prompt carrying + // attachments, goes through as usual. + if ( + offersUsageLimits && + isUsageLimitsCommand(initialMessageText) && + draft.attachments.length === 0 + ) { + Alert.alert( + "Usage limits", + "Send /usage-limits inside a thread, or open Settings → Usage → Limits.", + ); + return; + } // A failed-send restore can leave the draft over the cap on purpose (it // never drops the user's files); starting anyway would upload everything // and have the server reject the turn. diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index af3359ec8c79..d61d7b92d0fb 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -7,7 +7,13 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + UsageLimitsReport, } from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; import { StackActions, useFocusEffect, useNavigation } from "@react-navigation/native"; import type { ReactNode } from "react"; import { @@ -20,7 +26,7 @@ import { useState, type RefObject, } from "react"; -import { ActivityIndicator, Platform, Pressable, View, type ViewStyle } from "react-native"; +import { ActivityIndicator, Alert, Platform, Pressable, View, type ViewStyle } from "react-native"; import { FilePreviewModal, type FilePreviewSource } from "../../components/FilePreviewModal"; import { composerAttachmentUploadBlockReason, @@ -124,6 +130,8 @@ export interface ThreadComposerProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + /** `/usage-limits` resolves locally; the host decides where the report shows. Null clears it. */ + readonly onShowUsageLimits: (report: UsageLimitsReport | null) => void; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -336,6 +344,30 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer ); }, [props.serverConfig, props.selectedThread.modelSelection.instanceId]); const composerOwnerKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + const { onSendMessage, onChangeDraftMessage, onShowUsageLimits } = props; + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + selectedProviderStatus !== null && + hasProviderUsageLimits( + selectedProviderStatus.driver, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + ); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const report = collectProviderUsageLimits( + currentModelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + Date.now(), + ); + onShowUsageLimits(report); + if (!report) { + Alert.alert("Usage limits unavailable", "This provider does not currently report limits."); + } + return report !== null; + }, [currentModelSelection.instanceId, onShowUsageLimits, props.serverConfig]); const composerMenu = useComposerCommandMenu({ draftMessage: props.draftMessage, @@ -350,6 +382,10 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer selectedProviderStatus?.showInteractionModeToggle === false ? undefined : props.onUpdateInteractionMode, + offersUsageLimits: usageLimitsOffered, + // With attachments aboard the pick just inserts the text, so it sends as a prompt. + onUsageLimits: + usageLimitsOffered && props.draftAttachments.length === 0 ? openUsageLimits : undefined, }); const voiceInput = useVoiceInputController({ ownerKey: composerOwnerKey, @@ -428,9 +464,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer } onEditorFocusChange?.(false); }, [onEditorFocusChange, onExpandedChange, settingsSheetPresentation.isActive]); - const { onSendMessage } = props; - const handleSend = useCallback(async () => { + // Typed out in full rather than picked from the menu. Attachments mean the + // user is sending a prompt, so those go through as usual. + if ( + usageLimitsOffered && + isUsageLimitsCommand(props.draftMessage) && + props.draftAttachments.length === 0 + ) { + if (openUsageLimits()) onChangeDraftMessage(""); + return; + } if (voiceInput.blocksSubmission) return; const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); if (inFlightThreadIdsRef.current.has(threadKey)) return; @@ -453,6 +497,11 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer inFlightThreadIdsRef.current.delete(threadKey); } }, [ + props.draftMessage, + props.draftAttachments.length, + onChangeDraftMessage, + openUsageLimits, + usageLimitsOffered, onSendMessage, props.environmentId, props.environmentLabel, diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index d0e553ebdcf9..52ab91a5f496 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -19,6 +19,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, + UsageLimitsReport, UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; @@ -57,6 +58,7 @@ import Animated, { import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; +import { collectProviderUsageLimits } from "@t3tools/shared/usageLimits"; import type { ComposerEditorHandle } from "../../components/ComposerEditor"; import type { StatusTone } from "../../components/StatusPill"; import type { DraftComposerAttachment } from "../../lib/composerImages"; @@ -70,6 +72,7 @@ import type { ThreadFeedEntry, } from "../../lib/threadActivity"; import { PendingApprovalCard } from "./PendingApprovalCard"; +import { ComposerUsageLimits } from "./ComposerUsageLimits"; import { PendingUserInputCard } from "./PendingUserInputCard"; import { FLOATING_WORKING_CONTROL_COVERAGE, @@ -359,6 +362,68 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread const [collapsedUserInputRequestId, setCollapsedUserInputRequestId] = useState(null); const activeUserInputRequestId = props.activePendingUserInput?.requestId ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = [ + selectedThreadKey, + props.selectedThread.modelSelection.instanceId, + props.selectedThread.latestTurn?.turnId ?? "", + props.activePendingApproval?.requestId ?? props.activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the key changes so it cannot resurface stale. + if (usageLimitsPanel !== null && usageLimitsPanel.key !== usageLimitsKey) { + setUsageLimitsPanel(null); + } + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && usageLimitsPanel.key === usageLimitsKey + ? collectProviderUsageLimits( + props.selectedThread.modelSelection.instanceId, + props.serverConfig?.providers ?? [], + props.serverConfig?.usageLimitSources ?? [], + usageLimitsPanel.now, + ) + : null, + [ + props.selectedThread.modelSelection.instanceId, + props.serverConfig, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const showUsageLimits = useCallback( + (report: UsageLimitsReport | null) => + setUsageLimitsPanel( + report === null + ? null + : { + key: usageLimitsKey, + threadKey: selectedThreadKey, + now: Date.parse(report.createdAt), + }, + ), + [selectedThreadKey, usageLimitsKey], + ); + const dismissUsageLimits = useCallback(() => setUsageLimitsPanel(null), []); + // A send may resolve after navigating away, so only the originating + // thread's panel is cleared; a panel opened elsewhere in the meantime stays. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const userInputCollapsed = activeUserInputRequestId !== null && collapsedUserInputRequestId === activeUserInputRequestId; // The card's height RESERVES keyboard space at all times instead of @@ -623,6 +688,9 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; } + // A sent message makes the snapshot stale; a refused send leaves it in place. + clearUsageLimitsFor(targetThreadKey); + setSubmittedMessageId(messageId); setAnchorMessageId( resolveThreadFeedSubmissionAnchor({ @@ -637,6 +705,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread return messageId; }, [ anchorMessageId, + clearUsageLimitsFor, props.onSendMessage, props.selectedThread.latestTurn, props.selectedThreadQueueCount, @@ -778,6 +847,19 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onScrollToEnd={handleScrollToEnd} /> + {usageLimitsReport && activeUserInputRequestId === null ? ( + + + + ) : null} {props.activePendingApproval || props.activePendingUserInput ? ( void; readonly onUpdateInteractionMode?: (mode: ProviderInteractionMode) => void; + /** Picking /usage-limits is the action itself; the draft keeps nothing of it. */ + readonly onUsageLimits?: () => void; }) { const [selection, setSelection] = useState(() => composerSelectionAtEnd(draftMessage)); const previousOwnerKeyRef = useRef(ownerKey); @@ -267,6 +281,7 @@ export function useComposerCommandMenu({ atMessageStart: trigger.rangeStart === 0, hasThread, hasCompactableConversation, + offersUsageLimits, allowInteractionMode: onUpdateInteractionMode !== undefined, selectedProviderStatus, }); @@ -390,12 +405,25 @@ export function useComposerCommandMenu({ selectedProviderStatus, skills, trigger, + offersUsageLimits, ]); const onSelect = useCallback( (item: ComposerCommandItem) => { if (!trigger) return; + if ( + item.type === "provider-slash-command" && + item.command.name === USAGE_LIMITS_COMMAND.name && + onUsageLimits + ) { + const cleared = replaceTextRange(draftMessage, trigger.rangeStart, trigger.rangeEnd, ""); + setSelection({ start: cleared.cursor, end: cleared.cursor }); + onChangeDraftMessage(cleared.text); + onUsageLimits(); + return; + } + const result = resolveComposerCommandSelection({ draftMessage, trigger, @@ -414,6 +442,7 @@ export function useComposerCommandMenu({ draftMessage, onChangeDraftMessage, onUpdateInteractionMode, + onUsageLimits, selectedProviderStatus?.showInteractionModeToggle, trigger, ], diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 0668efa30053..ca0608738fe9 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -69,9 +69,9 @@ function WindowRow(props: { = 90 - ? "h-full rounded-full bg-destructive" + ? "h-full rounded-full bg-red-500" : used >= 70 - ? "h-full rounded-full bg-warning" + ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ @@ -99,7 +99,7 @@ function WindowRow(props: { } /** One account: icon, name and plan on a single line, then its windows. */ -function AccountLimits(props: { +export function AccountLimits(props: { readonly driver: Driver; readonly label: string; readonly instanceLabel: string; @@ -107,14 +107,23 @@ function AccountLimits(props: { readonly limits: ServerProvider["usageLimits"]; readonly now: number; readonly first: boolean; + /** Tighter padding for the composer card. */ + readonly dense?: boolean; + /** Sits at the end of the heading row, such as a close control. */ + readonly trailing?: ReactNode; readonly footer?: ReactNode; }) { - const { limits, now } = props; + const { limits, now, dense = false } = props; const color = useBarColor(props.driver); if (!limits) return null; const notice = limitsNotice(limits); + const padding = dense ? "px-4 py-3" : "p-4"; return ( - + @@ -130,6 +139,7 @@ function AccountLimits(props: { ) : null} + {props.trailing} {notice ? ( {notice} @@ -157,13 +167,15 @@ const OUTCOME_TEXT: Record = { * credit the provider granted the user, so it goes through the native * confirm alert rather than firing on a bare tap. */ -function ResetCredits(props: { +export function ResetCredits(props: { readonly environmentId: EnvironmentId; readonly instanceId: ProviderInstanceId; readonly credits: ServerProviderResetCredits; readonly now: number; + /** A smaller pill for the composer card. */ + readonly dense?: boolean; }) { - const { environmentId, instanceId, credits, now } = props; + const { environmentId, instanceId, credits, now, dense = false } = props; const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false, }); @@ -217,10 +229,20 @@ function ResetCredits(props: { accessibilityState={{ disabled: busy }} disabled={busy} onPress={confirm} - className="rounded-full bg-subtle-strong px-3 py-1.5" + className={ + dense + ? "rounded-full bg-subtle-strong px-2.5 py-1" + : "rounded-full bg-subtle-strong px-3 py-1.5" + } > - - {busy ? "Using credit…" : "Use a reset credit"} + + {busy ? "Using…" : "Use reset"} ) : null} diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 2157c72e13ef..28cd2af57062 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -8,6 +8,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index cfebfcf157c3..56889c1e63b2 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -39,6 +39,7 @@ import { type ServerLifecycleStreamEvent, ThreadId, TurnId, + UsageLimitSourceId, WS_METHODS, WsRpcGroup, EditorId, @@ -498,6 +499,7 @@ const buildAppUnderTest = (options?: { keybindings?: Partial; environmentTheme?: Partial; providerRegistry?: Partial; + usageLimitSources?: Partial; providerService?: Partial; providerAuth?: Partial; providerInstanceRegistry?: Partial; @@ -756,8 +758,9 @@ const buildAppUnderTest = (options?: { }), Layer.mock(UsageLimitSources.UsageLimitSources)({ current: Effect.succeed([]), - streamChanges: Stream.empty, + streamChanges: Stream.make([]), refresh: Effect.void, + ...options?.layers?.usageLimitSources, }), ), ), @@ -6246,10 +6249,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); - it.effect("routes websocket rpc subscribeServerConfig emits provider status updates", () => - Effect.gen(function* () { - const nextProviders = [ - { + it.effect.each([false, true])( + "routes websocket rpc subscribeServerConfig emits provider status updates (limits: %s)", + (hasLimits) => + Effect.gen(function* () { + const nextProviders = [ + { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + ...(hasLimits + ? { + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + } + : {}), + }, + ] as const; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([]), + streamChanges: Stream.succeed(nextProviders), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, []); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: hasLimits + ? [ + { + ...nextProviders[0], + slashCommands: [ + { + name: "usage-limits", + description: "Show this provider's usage limits", + }, + ], + }, + ] + : nextProviders, + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc subscribeServerConfig keeps the limits command from clients that do not ask for it", + () => + Effect.gen(function* () { + const codex = { instanceId: ProviderInstanceId.make("codex"), driver: ProviderDriverKind.make("codex"), enabled: true, @@ -6261,43 +6348,129 @@ it.layer(NodeServices.layer)("server router seam", (it) => { models: [], slashCommands: [], skills: [], - }, - ] as const; - - yield* buildAppUnderTest({ - layers: { - keybindings: { - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], - }), - streamChanges: Stream.empty, + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [{ id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }], }, - providerRegistry: { - getProviders: Effect.succeed([]), - streamChanges: Stream.succeed(nextProviders), + }; + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.succeed([{ ...codex, version: "1.0.1" }]), + }, }, - }, - }); + }); - const wsUrl = yield* getWsServerUrl("/ws"); - const events = yield* Effect.scoped( - withWsRpcClient(wsUrl, (client) => - client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), - ), - ); + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({}).pipe(Stream.take(2), Stream.runCollect), + ), + ); - const [first, second] = Array.from(events); - assert.equal(first?.type, "snapshot"); - if (first?.type === "snapshot") { - assert.deepEqual(first.config.providers, []); - } - assert.deepEqual(second, { - version: 1, - type: "providerStatuses", - payload: { providers: nextProviders }, - }); - }).pipe(Effect.provide(NodeHttpServer.layerTest)), + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { providers: [{ ...codex, version: "1.0.1" }] }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect( + "routes websocket rpc subscribeServerConfig republishes commands when only a limits source changes", + () => + Effect.gen(function* () { + const codex = { + instanceId: ProviderInstanceId.make("codex"), + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + version: "1.0.0", + status: "ready" as const, + auth: { status: "authenticated" as const }, + checkedAt: "2026-04-11T00:00:00.000Z", + models: [], + slashCommands: [], + skills: [], + }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-04-11T00:00:00.000Z", + accounts: [ + { + id: "work", + driver: ProviderDriverKind.make("codex"), + usageLimits: { + checkedAt: "2026-04-11T00:00:00.000Z", + windows: [ + { id: "weekly", kind: "weekly" as const, label: "Weekly", usedPercent: 25 }, + ], + }, + }, + ], + }; + + yield* buildAppUnderTest({ + layers: { + keybindings: { + loadConfigState: Effect.succeed({ keybindings: [], issues: [] }), + streamChanges: Stream.empty, + }, + // The registry emits no change: only the source refresh can carry it. + providerRegistry: { + getProviders: Effect.succeed([codex]), + streamChanges: Stream.empty, + }, + usageLimitSources: { + current: Effect.succeed([]), + // Replay the empty snapshot, then a later refresh, as the live stream does. + streamChanges: Stream.concat(Stream.make([]), Stream.make([hub])), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const events = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.subscribeServerConfig]({ usageLimitsCommand: true }).pipe( + Stream.take(2), + Stream.runCollect, + ), + ), + ); + + const [first, second] = Array.from(events); + assert.equal(first?.type, "snapshot"); + if (first?.type === "snapshot") { + assert.deepEqual(first.config.providers, [codex]); + } + assert.deepEqual(second, { + version: 1, + type: "providerStatuses", + payload: { + providers: [ + { + ...codex, + slashCommands: [ + { name: "usage-limits", description: "Show this provider's usage limits" }, + ], + }, + ], + }, + }); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); it.effect( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 6261f7bc5287..fa29d5bd9847 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1,3 +1,7 @@ +import { + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, +} from "@t3tools/shared/usageLimits"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; @@ -1221,59 +1225,67 @@ const makeWsRpcLayer = ( ); }; - const loadServerConfig = Effect.gen(function* () { - const keybindingsConfig = yield* keybindings.loadConfigState; - const providers = yield* providerRegistry.getProviders; - const settings = ServerSettings.redactServerSettingsForClient( - yield* serverSettings.getSettings, - ); - const environment = yield* serverEnvironment.getDescriptor; - const auth = yield* serverAuth.getDescriptor(); - const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( - externalLauncher.resolveAvailableEditors(), - ); - const fileManagerRevealKind = availableEditors.includes("file-manager") - ? yield* resolveFileManagerRevealKindForConfig( - externalLauncher.resolveFileManagerRevealKind(), - ) - : undefined; - - return { - environment, - auth, - cwd: config.cwd, - keybindingsConfigPath: config.keybindingsConfigPath, - keybindings: keybindingsConfig.keybindings, - issues: keybindingsConfig.issues, - providers, - availableEditors, - // Same discovery-with-timeout treatment as editors: a slow probe - // must not stall server.getConfig, so it degrades to no targets. - remoteOpenTargets: yield* resolveAvailableEditorsForConfig( - remoteOpenTargets.resolveTargets(), - ), - observability: { - logsDirectoryPath: config.logsDir, - localTracingEnabled: true, - ...(config.otlpTracesUrl !== undefined ? { otlpTracesUrl: config.otlpTracesUrl } : {}), - otlpTracesEnabled: config.otlpTracesUrl !== undefined, - ...(config.otlpMetricsUrl !== undefined - ? { otlpMetricsUrl: config.otlpMetricsUrl } - : {}), - otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, - }, - settings, - shellResumeCompletionMarker: true, - ...(fileManagerRevealKind === undefined - ? {} - : { - shellRevealInFileManager: true, - shellRevealInFileManagerKind: fileManagerRevealKind, - }), - threadResumeCompletionMarker: true, - threadSnapshotPagination: true, - }; - }); + // Only clients that answer /usage-limits themselves see it in the catalogs; + // an older client would send the injected command to the provider. + const loadServerConfig = (options: { readonly usageLimitsCommand: boolean }) => + Effect.gen(function* () { + const keybindingsConfig = yield* keybindings.loadConfigState; + const currentProviders = yield* providerRegistry.getProviders; + const providers = options.usageLimitsCommand + ? withUsageLimitsCommands(currentProviders, yield* usageLimitSources.current) + : currentProviders; + const settings = ServerSettings.redactServerSettingsForClient( + yield* serverSettings.getSettings, + ); + const environment = yield* serverEnvironment.getDescriptor; + const auth = yield* serverAuth.getDescriptor(); + const availableEditors: ReadonlyArray = yield* resolveAvailableEditorsForConfig( + externalLauncher.resolveAvailableEditors(), + ); + const fileManagerRevealKind = availableEditors.includes("file-manager") + ? yield* resolveFileManagerRevealKindForConfig( + externalLauncher.resolveFileManagerRevealKind(), + ) + : undefined; + + return { + environment, + auth, + cwd: config.cwd, + keybindingsConfigPath: config.keybindingsConfigPath, + keybindings: keybindingsConfig.keybindings, + issues: keybindingsConfig.issues, + providers, + availableEditors, + // Same discovery-with-timeout treatment as editors: a slow probe + // must not stall server.getConfig, so it degrades to no targets. + remoteOpenTargets: yield* resolveAvailableEditorsForConfig( + remoteOpenTargets.resolveTargets(), + ), + observability: { + logsDirectoryPath: config.logsDir, + localTracingEnabled: true, + ...(config.otlpTracesUrl !== undefined + ? { otlpTracesUrl: config.otlpTracesUrl } + : {}), + otlpTracesEnabled: config.otlpTracesUrl !== undefined, + ...(config.otlpMetricsUrl !== undefined + ? { otlpMetricsUrl: config.otlpMetricsUrl } + : {}), + otlpMetricsEnabled: config.otlpMetricsUrl !== undefined, + }, + settings, + shellResumeCompletionMarker: true, + ...(fileManagerRevealKind === undefined + ? {} + : { + shellRevealInFileManager: true, + shellRevealInFileManagerKind: fileManagerRevealKind, + }), + threadResumeCompletionMarker: true, + threadSnapshotPagination: true, + }; + }); const refreshGitStatus = (cwd: string) => vcsStatusBroadcaster @@ -1750,9 +1762,13 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }), [WS_METHODS.serverGetConfig]: (_input) => - observeRpcEffect(WS_METHODS.serverGetConfig, loadServerConfig, { - "rpc.aggregate": "server", - }), + observeRpcEffect( + WS_METHODS.serverGetConfig, + loadServerConfig({ usageLimitsCommand: false }), + { + "rpc.aggregate": "server", + }, + ), [WS_METHODS.serverRefreshProviders]: (input) => observeRpcEffect( WS_METHODS.serverRefreshProviders, @@ -2692,6 +2708,8 @@ const makeWsRpcLayer = ( observeRpcStreamEffect( WS_METHODS.subscribeServerConfig, Effect.gen(function* () { + const usageLimitsCommand = input.usageLimitsCommand === true; + const config = yield* loadServerConfig({ usageLimitsCommand }); const keybindingsUpdates = keybindings.streamChanges.pipe( Stream.map((event) => ({ version: 1 as const, @@ -2702,7 +2720,33 @@ const makeWsRpcLayer = ( }, })), ); - const providerStatuses = providerRegistry.streamChanges.pipe( + const providerStatuses = Stream.zipLatestWith( + // The registry stream carries changes only. Seed it with the current + // providers so a source refresh that lands before any provider change + // still pairs up and reaches the client. + Stream.concat( + Stream.fromEffect(providerRegistry.getProviders), + providerRegistry.streamChanges, + ), + usageLimitSources.streamChanges.pipe( + // Quota updates already have their own stream. Republish the model + // catalog only when the set of providers offered the command changes. + Stream.changesWith( + usageLimitsCommand ? sameUsageLimitCommandCoverage : () => true, + ), + ), + (providers, sources) => + usageLimitsCommand ? withUsageLimitsCommands(providers, sources) : providers, + ).pipe( + // Both sides replay their current value, so the first pairing normally + // repeats the snapshot the client already holds. Compare against that + // snapshot rather than dropping blindly: a refresh that landed between + // the snapshot and the subscription still goes out. + (updates) => Stream.concat(Stream.make(config.providers), updates), + Stream.changesWith( + (previous, next) => JSON.stringify(previous) === JSON.stringify(next), + ), + Stream.drop(1), Stream.map((providers) => ({ version: 1 as const, type: "providerStatuses" as const, @@ -2763,11 +2807,7 @@ const makeWsRpcLayer = ( ); return Stream.concat( - Stream.make({ - version: 1 as const, - type: "snapshot" as const, - config: yield* loadServerConfig, - }), + Stream.make({ version: 1 as const, type: "snapshot" as const, config }), liveUpdates, ); }), diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6173c760ceeb..c9c2c60badb5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,3 +1,10 @@ +import type { UsageLimitSourceSnapshots } from "@t3tools/contracts"; +import { + collectProviderUsageLimits, + hasProviderUsageLimits, + isUsageLimitsCommand, +} from "@t3tools/shared/usageLimits"; +import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; import { type AssistantCitation, type ApprovalRequestId, @@ -451,6 +458,7 @@ import { ATTACHMENT_ONLY_BOOTSTRAP_PROMPT } from "./chat/composerPromptHistory"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { @@ -1505,6 +1513,11 @@ export default function ChatView(props: ChatViewProps) { const draft = store.getComposerDraft(composerDraftTarget); return (draft?.images.length ?? 0) > 0 || (draft?.files.length ?? 0) > 0; }); + // Anything beyond the prompt text: attachments, terminal or element contexts, annotations. + const composerHasNonPromptContent = useComposerDraftStore((store) => { + const draft = store.getComposerDraft(composerDraftTarget); + return draft ? composerDraftHasUserContent({ ...draft, prompt: "" }) : false; + }); const setComposerDraftPrompt = useComposerDraftStore((store) => store.setPrompt); const addComposerDraftImages = useComposerDraftStore((store) => store.addImages); const addComposerDraftFiles = useComposerDraftStore((store) => store.addFiles); @@ -2620,6 +2633,111 @@ export default function ChatView(props: ChatViewProps) { hasComposerAttachments: composerHasAttachments, }); const activePendingApproval = pendingApprovals[0] ?? null; + // The open /usage-limits panel for this thread, model and turn. Only the open + // moment is stored: the rows read live provider data, so a redeemed reset + // credit or refreshed probe shows through. Anything that spends quota closes + // it: a new turn from any source, or the agent resuming after an approval or + // answered question. + const [usageLimitsPanel, setUsageLimitsPanel] = useState<{ + readonly key: string; + readonly threadKey: string; + readonly now: number; + } | null>(null); + // Null while the provider list or the thread itself is unavailable, such as + // during a reconnect; the panel then stays hidden rather than being dropped. + // A pending approval or question is part of the key: once it is answered, + // from this client or any other, the agent resumes and spends quota. + const usageLimitsKey = + activeProviderInstanceId === null || (isServerThread && activeThread === undefined) + ? null + : [ + routeThreadKey, + activeProviderInstanceId, + activeThread?.latestTurn?.turnId ?? "", + activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? "", + ].join(":"); + // Drop the snapshot as soon as the thread or model changes so it cannot resurface stale. + if ( + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key !== usageLimitsKey + ) { + setUsageLimitsPanel(null); + } + const usageLimitSources = serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES; + const usageLimitsReport = useMemo( + () => + usageLimitsPanel !== null && + usageLimitsKey !== null && + usageLimitsPanel.key === usageLimitsKey && + activeProviderInstanceId !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsPanel.now, + ) + : null, + [ + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + usageLimitsKey, + usageLimitsPanel, + ], + ); + const usageLimitsBanner = useMemo( + () => + usageLimitsReport !== null && usageLimitsPanel !== null + ? // A fresh id per opening: the stack keeps the last dismissed id as "exiting". + usageLimitsBannerItem( + `usage-limits:${usageLimitsPanel.key}:${usageLimitsPanel.now}`, + usageLimitsReport, + environmentId, + () => setUsageLimitsPanel(null), + ) + : null, + [environmentId, usageLimitsPanel, usageLimitsReport], + ); + // T3 owns /usage-limits only where Limits has data for the selected provider; + // elsewhere the name stays the provider's own and is sent through untouched. + const usageLimitsOffered = + activeProviderStatus !== null && + hasProviderUsageLimits(activeProviderStatus.driver, providerStatuses, usageLimitSources); + // Answered locally from the last Limits snapshot; the agent never sees it. + const openUsageLimits = useCallback(() => { + const now = Date.now(); + const report = + activeProviderInstanceId !== null && usageLimitsKey !== null + ? collectProviderUsageLimits( + activeProviderInstanceId, + providerStatuses, + usageLimitSources, + now, + ) + : null; + if (report && usageLimitsKey !== null) { + setUsageLimitsPanel({ key: usageLimitsKey, threadKey: routeThreadKey, now }); + return true; + } + setUsageLimitsPanel(null); + toastManager.add({ type: "info", title: "Usage limits are unavailable for this provider" }); + return false; + }, [ + activeProviderInstanceId, + providerStatuses, + routeThreadKey, + usageLimitSources, + usageLimitsKey, + ]); + // Responses can resolve after navigating away; only the originating thread's panel clears. + const clearUsageLimitsFor = useCallback( + (threadKey: string) => + setUsageLimitsPanel((current) => + current !== null && current.threadKey === threadKey ? null : current, + ), + [], + ); const { beginLocalDispatch, resetLocalDispatch, @@ -5599,8 +5717,11 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem === null ? [] : [resumeCompactionBannerItem]; const wokeThreadItems = wokeThreadBannerItem === null ? [] : [wokeThreadBannerItem]; const parkedThreadItems = parkedThreadBannerItem === null ? [] : [parkedThreadBannerItem]; + // The user asked for this one, so it leads the notice tier instead of trailing it. + const usageLimitsItems = usageLimitsBanner === null ? [] : [usageLimitsBanner]; if (!localCheckoutBranchMismatch || !showBranchMismatchBanner || !activeBranchMismatchKey) { return [ + ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -5609,6 +5730,7 @@ export default function ChatView(props: ChatViewProps) { ]; } return [ + ...usageLimitsItems, ...systemComposerBannerItems, ...backgroundLivenessItems, ...resumeCompactionItems, @@ -5663,6 +5785,7 @@ export default function ChatView(props: ChatViewProps) { resumeCompactionBannerItem, showBranchMismatchBanner, systemComposerBannerItems, + usageLimitsBanner, wokeThreadBannerItem, ]); useEffect(() => { @@ -6076,6 +6199,23 @@ export default function ChatView(props: ChatViewProps) { }, ) => { e?.preventDefault(); + // Typed out in full rather than picked from the menu. Attachments or contexts + // mean the user is sending a prompt, so those go through as usual. + if ( + usageLimitsOffered && + usageLimitsKey !== null && + !directAnnotation && + !composerHasNonPromptContent && + isUsageLimitsCommand(promptRef.current) + ) { + if (openUsageLimits()) { + promptRef.current = ""; + setComposerDraftPrompt(composerDraftTarget, ""); + composerRef.current?.resetCursorState(); + } + return; + } + const notifyDirectAnnotationAttached = () => { if (!directAnnotation) return; toastManager.add( @@ -6718,6 +6858,10 @@ export default function ChatView(props: ChatViewProps) { failure = startResult; } else { turnStartSucceeded = true; + // The turn is under way and will spend quota, so that thread's limits + // snapshot is stale. Uploads may have outlasted a navigation, so only + // the sending thread's panel clears. + clearUsageLimitsFor(routeThreadKey); if (turnUsesAttachmentUploads) { releaseDraftAttachments(composerAttachmentsSnapshot); } @@ -7138,6 +7282,7 @@ export default function ChatView(props: ChatViewProps) { } if (failure === null) { + clearUsageLimitsFor(routeThreadKey); acknowledgeActiveThreadWoke(); sendInFlightRef.current = false; return; @@ -7174,6 +7319,8 @@ export default function ChatView(props: ChatViewProps) { startThreadTurn, environmentId, composerRef, + clearUsageLimitsFor, + routeThreadKey, ], ); @@ -7949,6 +8096,15 @@ export default function ChatView(props: ChatViewProps) { } isPreparingWorktree={isPreparingWorktree} bannerItems={composerBannerItems} + // With attachments or contexts aboard the pick just inserts the + // text, so it sends as a prompt like the typed path would. + onUsageLimitsCommand={ + usageLimitsOffered && + usageLimitsKey !== null && + !composerHasNonPromptContent + ? openUsageLimits + : undefined + } environmentUnavailable={activeEnvironmentUnavailableState} activePendingApproval={activePendingApproval} pendingApprovals={pendingApprovals} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 30acb44dd423..9772efc3290e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -22,6 +22,7 @@ import { import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; +import { USAGE_LIMITS_COMMAND } from "@t3tools/shared/usageLimits"; import { Fragment, memo, @@ -1191,6 +1192,8 @@ export interface ChatComposerProps { sendDisabledReason: string | null; isPreparingWorktree: boolean; bannerItems: readonly ComposerBannerStackItem[]; + /** Picking /usage-limits from the menu is the action itself; the draft keeps nothing of it. */ + onUsageLimitsCommand?: (() => void) | undefined; environmentUnavailable: { readonly label: string; readonly connection: EnvironmentConnectionPresentation; @@ -2684,6 +2687,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) }; }, [readComposerSnapshot]); + const { onUsageLimitsCommand } = props; const onSelectComposerItem = useCallback( (item: ComposerCommandItem) => { if (composerSelectLockRef.current) return; @@ -2734,6 +2738,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return; } if (item.type === "provider-slash-command") { + if (item.command.name === USAGE_LIMITS_COMMAND.name && onUsageLimitsCommand) { + const applied = applyPromptReplacement(trigger.rangeStart, trigger.rangeEnd, "", { + expectedText: snapshot.value.slice(trigger.rangeStart, trigger.rangeEnd), + focusEditorAfterReplace: false, + }); + if (applied) { + setComposerHighlightedItemId(null); + onUsageLimitsCommand(); + } + return; + } const replacement = `/${item.command.name} `; const replacementRangeEnd = extendReplacementRangeForTrailingSpace( snapshot.value, @@ -2774,6 +2789,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) applyPromptReplacement, handleInteractionModeChange, planModeUiEnabled, + onUsageLimitsCommand, resolveActiveComposerTrigger, ], ); diff --git a/apps/web/src/components/chat/ComposerUsageLimits.tsx b/apps/web/src/components/chat/ComposerUsageLimits.tsx new file mode 100644 index 000000000000..384492b5789e --- /dev/null +++ b/apps/web/src/components/chat/ComposerUsageLimits.tsx @@ -0,0 +1,97 @@ +import type { EnvironmentId, UsageLimitsReport } from "@t3tools/contracts"; +import { limitsNotice } from "@t3tools/shared/usageLimits"; +import { GaugeIcon } from "lucide-react"; + +import { getDriverOption } from "../settings/providerDriverMeta"; +import { LimitWindows, ResetCredits } from "../usage/UsageLimits"; +import { ComposerBanner } from "./ComposerBanner"; +import type { ComposerBannerStackItem } from "./ComposerBannerStack"; + +/** Driver name, then the instance when there could be more than one of that driver. */ +function accountLabel(account: UsageLimitsReport["accounts"][number]): string { + if (!account.instanceId) return account.label; + const driver = getDriverOption(account.driver)?.label ?? String(account.driver); + const instance = + account.displayName?.trim() || + (String(account.instanceId) !== String(account.driver) ? account.instanceId : ""); + // The default instance is often named after its driver; saying it twice adds nothing. + return instance && instance.toLowerCase() !== driver.toLowerCase() + ? `${driver} · ${instance}` + : driver; +} + +/** The /usage-limits result as a composer notice: it stacks under warnings and dismisses like one. */ +export function usageLimitsBannerItem( + id: string, + report: UsageLimitsReport, + environmentId: EnvironmentId, + onDismiss: () => void, +): ComposerBannerStackItem { + const [first] = report.accounts; + const single = report.accounts.length === 1 && first ? first : null; + const summary = single + ? [accountLabel(single), single.plan].filter(Boolean).join(" · ") + : `${report.accounts.length} accounts`; + return { + id, + variant: "info", + priority: "notice", + icon: , + title: "Usage limits", + description: summary, + dismissLabel: "Dismiss usage limits", + onDismiss, + children: , + }; +} + +function UsageLimitsBannerBody({ + report, + environmentId, +}: { + readonly report: UsageLimitsReport; + readonly environmentId: EnvironmentId; +}) { + const now = Date.parse(report.createdAt); + return ( + + + {report.accounts.map((account) => { + const notice = limitsNotice(account.limits); + return ( +
+ {report.accounts.length > 1 ? ( + + {[accountLabel(account), account.plan].filter(Boolean).join(" · ")} + + ) : null} + {notice ? ( + {notice} + ) : ( + + )} + {account.instanceId && account.limits.resetCredits ? ( + + ) : null} +
+ ); + })} + {report.notices.map((notice) => ( + + {notice} + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 77f85953984c..5584cfc169f5 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -153,24 +153,31 @@ function WindowBar({ ); } -/** One account's windows as rows: label and percent, bar, pace and countdown. */ -function LimitWindows({ +/** + * One account's windows as rows: label and percent, bar, pace and countdown. + * Compact rows fit the composer panel with narrower columns. + */ +export function LimitWindows({ driver, windows, now, + compact = false, }: { readonly driver: ServerProvider["driver"]; readonly windows: ReadonlyArray; readonly now: number; + readonly compact?: boolean; }) { const color = barColor(driver); return ( -
- {windows.map((window, index) => { - // Windows that reset together show the countdown once. - const previous = windows[index - 1]; - const sharesReset = - previous?.resetsAt !== undefined && previous.resetsAt === window.resetsAt; +
+ {windows.map((window) => { const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( @@ -182,9 +189,9 @@ function LimitWindows({ - + {pace ? : null} - {sharesReset ? "" : (resetsIn ?? "")} + {resetsIn ?? ""} ); @@ -292,7 +299,7 @@ const OUTCOME_TEXT: Record = { * Banked reset credits with a confirmed redeem action. Redeeming spends a * credit the provider granted the user, so it never fires on a bare click. */ -function ResetCredits({ +export function ResetCredits({ environmentId, instanceId, credits, @@ -341,7 +348,7 @@ function ResetCredits({ {summary} {credits.availableCount > 0 ? ( ) : null} {status ? {status} : null} diff --git a/apps/web/src/connection/runtime.ts b/apps/web/src/connection/runtime.ts index ac2316560d98..b5e78287f263 100644 --- a/apps/web/src/connection/runtime.ts +++ b/apps/web/src/connection/runtime.ts @@ -32,7 +32,11 @@ type ConnectionLayerSource = const providedClientConnectionLayer = snapshotLoaderLayer.pipe( Layer.provideMerge( - Connection.layerWithOptions({ environmentThemes: true, usageLimitSources: true }), + Connection.layerWithOptions({ + environmentThemes: true, + usageLimitSources: true, + usageLimitsCommand: true, + }), ), Layer.provideMerge( Layer.mergeAll( diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 0eacc933da49..31b9436621c9 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -29,6 +29,7 @@ export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRunt initialConfigValueAtom: environmentSession.initialConfigValueAtom, environmentThemes: true, usageLimitSources: true, + usageLimitsCommand: true, }); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, diff --git a/docs/user/usage.md b/docs/user/usage.md index a0231856ef69..dc92b3f65917 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -45,6 +45,11 @@ next reset. If a window looks stale, refresh Limits to re-check every provider and hub. +Pick `/usage-limits` from the composer's command menu, or send it as a message, to check the +current model's limits without leaving the conversation. The result opens above the composer and +closes when you dismiss it or send your next message. It uses the same snapshot as **Usage → Limits**, so it does not run the agent or refresh +anything. The command is offered only for providers that appear under **Usage → Limits**. + API-key accounts may not report subscription limits. This also applies to Claude connections using a proxy through `ANTHROPIC_AUTH_TOKEN`. diff --git a/packages/client-runtime/src/rpc/session.ts b/packages/client-runtime/src/rpc/session.ts index 2402c1324565..3e353f6be4df 100644 --- a/packages/client-runtime/src/rpc/session.ts +++ b/packages/client-runtime/src/rpc/session.ts @@ -57,6 +57,8 @@ export interface RpcSession { export interface RpcSessionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + /** This client answers /usage-limits itself, so the server may advertise it. */ + readonly usageLimitsCommand?: boolean; } export class RpcSessionFactory extends Context.Service< @@ -153,6 +155,7 @@ export const make = Effect.fn("RpcSessionFactory.make")(function* ( const serverConfigInput: ServerConfigSubscriptionInput = { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }; const connect = Effect.fnUntraced(function* (connection: PreparedConnection) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index e209acfd1e7d..5df29a8629d4 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -358,6 +358,7 @@ const cachedConfigSnapshotEvent = (config: ServerConfig): ServerConfigStreamEven export interface ServerConfigSubscriptionOptions { readonly environmentThemes?: boolean; readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; } export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConfigState.make")( @@ -425,6 +426,7 @@ export const makeEnvironmentServerConfigState = Effect.fn("EnvironmentServerConf yield* subscribe(WS_METHODS.subscribeServerConfig, { ...(subscription.environmentThemes === true ? { environmentThemes: true } : {}), ...(subscription.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(subscription.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }).pipe( Stream.runForEach((event) => Effect.gen(function* () { @@ -621,6 +623,7 @@ export function createServerEnvironmentAtoms( readonly environmentThemes?: boolean; /** Whether this surface renders quota from configured usage-limit sources. */ readonly usageLimitSources?: boolean; + readonly usageLimitsCommand?: boolean; }, ) { const configScheduler = createAtomCommandScheduler(); @@ -636,6 +639,7 @@ export function createServerEnvironmentAtoms( serverConfigStateChanges(environmentId, { ...(options.environmentThemes === true ? { environmentThemes: true } : {}), ...(options.usageLimitSources === true ? { usageLimitSources: true } : {}), + ...(options.usageLimitsCommand === true ? { usageLimitsCommand: true } : {}), }), ) .pipe( diff --git a/packages/contracts/src/providerUsageLimits.ts b/packages/contracts/src/providerUsageLimits.ts index 0478b113d61d..554e68c1b200 100644 --- a/packages/contracts/src/providerUsageLimits.ts +++ b/packages/contracts/src/providerUsageLimits.ts @@ -121,3 +121,24 @@ export const ProviderConsumeResetCreditResult = Schema.Struct({ outcome: ProviderConsumeResetCreditOutcome, }); export type ProviderConsumeResetCreditResult = typeof ProviderConsumeResetCreditResult.Type; + +/** A point-in-time view of one provider's limits, built for the /usage-limits panel. */ +export const UsageLimitsReport = Schema.Struct({ + createdAt: IsoDateTime, + accounts: Schema.Array( + Schema.Struct({ + id: TrimmedNonEmptyString, + driver: ProviderDriverKind, + label: TrimmedNonEmptyString, + plan: Schema.optional(TrimmedNonEmptyString), + email: Schema.optional(TrimmedNonEmptyString), + sourceLabel: Schema.optional(TrimmedNonEmptyString), + instanceId: Schema.optional(ProviderInstanceId), + displayName: Schema.optional(Schema.String), + accentColor: Schema.optional(Schema.String), + limits: ServerProviderUsageLimits, + }), + ), + notices: Schema.Array(Schema.String), +}); +export type UsageLimitsReport = typeof UsageLimitsReport.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 69c0ab43e890..12c653f70cc4 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -1132,6 +1132,12 @@ export const WsSubscribeServerConfigRpc = Rpc.make(WS_METHODS.subscribeServerCon environmentThemes: Schema.optional(Schema.Boolean), /** Whether this client understands `usageLimitSourcesUpdated` events. */ usageLimitSources: Schema.optional(Schema.Boolean), + /** + * Whether this client answers `/usage-limits` itself. The server injects + * that command into provider catalogs only for such clients; an older + * client would send it to the provider as an ordinary prompt. + */ + usageLimitsCommand: Schema.optional(Schema.Boolean), }), success: ServerConfigStreamEvent, error: Schema.Union([KeybindingsConfigError, ServerSettingsError, EnvironmentAuthorizationError]), diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 83ac6906c61e..6b1952199dd2 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -9,6 +9,10 @@ import { import { describe, expect, it } from "vite-plus/test"; import { + isUsageLimitsCommand, + collectProviderUsageLimits, + sameUsageLimitCommandCoverage, + withUsageLimitsCommands, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -304,3 +308,196 @@ describe("collectLimitSources", () => { ]); }); }); + +describe("/usage-limits", () => { + const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; + const selected = provider({ + usageLimits: limits, + auth: { status: "authenticated", email: "same@example.com" }, + }); + const sources = [ + { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: limits.checkedAt, + accounts: [ + { + id: "duplicate", + driver: selected.driver, + email: "SAME@example.com", + usageLimits: limits, + }, + { id: "oss", driver: selected.driver, plan: "Codex OSS", usageLimits: limits }, + { id: "other-provider", driver: ProviderDriverKind.make("claude"), usageLimits: limits }, + ], + }, + ]; + + it("keeps accounts and custom instances separate, filtering by driver", () => { + const report = collectProviderUsageLimits( + selected.instanceId, + [ + selected, + provider({ + instanceId: ProviderInstanceId.make("codex-work"), + displayName: "Work", + usageLimits: { ...limits, resetCredits: { availableCount: 2 } }, + }), + provider({ + driver: ProviderDriverKind.make("claude"), + instanceId: ProviderInstanceId.make("claude"), + usageLimits: limits, + }), + ], + sources, + now, + ); + expect(report?.createdAt).toBe("2026-09-03T12:00:00.000Z"); + expect(report?.accounts.map((account) => account.id)).toEqual([ + "codex", + "codex-work", + "hub:oss", + ]); + expect(report?.accounts[0]).toMatchObject({ + instanceId: selected.instanceId, + email: selected.auth.email, + }); + expect(report?.accounts[1]).toMatchObject({ + displayName: "Work", + limits: { resetCredits: { availableCount: 2 } }, + }); + expect(report?.accounts[2]).toMatchObject({ + label: "Accounts · oss", + sourceLabel: "CLI Proxy", + plan: "Codex OSS", + }); + expect(report?.notices).toEqual([]); + }); + + it("supports a source-only provider and keeps duplicates when the native probe failed", () => { + expect( + collectProviderUsageLimits(selected.instanceId, [provider({})], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["hub:duplicate", "hub:oss"]); + const failed = provider({ usageLimits: { ...limits, unavailable: { reason: "probeFailed" } } }); + expect( + collectProviderUsageLimits(selected.instanceId, [failed], sources, now)?.accounts.map( + (account) => account.id, + ), + ).toEqual(["codex", "hub:duplicate", "hub:oss"]); + expect(collectProviderUsageLimits(selected.instanceId, [provider({})], [], now)).toBeNull(); + expect( + collectProviderUsageLimits( + selected.instanceId, + [provider({ enabled: false, usageLimits: limits })], + [], + now, + ), + ).toBeNull(); + }); + + it("surfaces source errors only for sources that carry the selected driver", () => { + const failing = { ...sources[0]!, error: "token expired" }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [failing], now)?.notices, + ).toEqual(["Accounts: token expired"]); + const claudeOnly = { ...failing, accounts: failing.accounts.slice(2) }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [claudeOnly], now)?.notices, + ).toEqual([]); + // A read failure clears the accounts, so the error must not depend on a match. + const unreadable = { ...failing, accounts: [] }; + expect( + collectProviderUsageLimits(selected.instanceId, [selected], [unreadable], now)?.notices, + ).toEqual(["Accounts: token expired"]); + // A source-only provider still gets the report, carrying only the error. + const sourceOnly = collectProviderUsageLimits( + selected.instanceId, + [provider({})], + [unreadable], + now, + ); + expect(sourceOnly?.accounts).toEqual([]); + expect(sourceOnly?.notices).toEqual(["Accounts: token expired"]); + }); + + it("advertises global and workspace commands only for providers present in Limits", () => { + const withWorkspace = provider({ + workspaceSnapshots: [ + { cwd: "/tmp/project", checkedAt: limits.checkedAt, slashCommands: [], skills: [] }, + ], + }); + const [supported] = withUsageLimitsCommands([withWorkspace], sources); + expect(supported?.slashCommands.map((command) => command.name)).toEqual(["usage-limits"]); + expect( + supported?.workspaceSnapshots?.[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + expect(withUsageLimitsCommands([withWorkspace], [])[0]?.slashCommands).toEqual([]); + // A provider's own command of the same name is left alone without coverage. + const ownCommand = provider({ + slashCommands: [{ name: "usage-limits", description: "Provider's own" }], + }); + expect(withUsageLimitsCommands([ownCommand], [])[0]?.slashCommands).toEqual([ + { name: "usage-limits", description: "Provider's own" }, + ]); + const unreadable = { ...sources[0]!, accounts: [], error: "token expired" }; + expect( + withUsageLimitsCommands([withWorkspace], [unreadable])[0]?.slashCommands.map( + (command) => command.name, + ), + ).toEqual(["usage-limits"]); + expect( + withUsageLimitsCommands([selected], [])[0]?.slashCommands.map((command) => command.name), + ).toEqual(["usage-limits"]); + }); +}); + +describe("sameUsageLimitCommandCoverage", () => { + const codexAccount = { + id: "a", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt: "2026-09-03T11:00:00.000Z", windows: [] }, + }; + const base = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "Accounts", + checkedAt: "2026-09-03T11:00:00.000Z", + }; + it("ignores quota movement but not the drivers offered the command", () => { + const withCodex = [{ ...base, accounts: [codexAccount] }]; + const withCodexLater = [ + { + ...base, + accounts: [ + { + ...codexAccount, + usageLimits: { ...codexAccount.usageLimits, checkedAt: "2026-09-03T12:00:00.000Z" }, + }, + ], + }, + ]; + expect(sameUsageLimitCommandCoverage(withCodex, withCodexLater)).toBe(true); + expect(sameUsageLimitCommandCoverage(withCodex, [{ ...base, accounts: [] }])).toBe(false); + }); + it("treats a failed read as a change in coverage, in both directions", () => { + const empty = [{ ...base, accounts: [] }]; + const failed = [{ ...base, accounts: [], error: "token expired" }]; + expect(sameUsageLimitCommandCoverage(empty, failed)).toBe(false); + expect(sameUsageLimitCommandCoverage(failed, empty)).toBe(false); + expect( + sameUsageLimitCommandCoverage(failed, [{ ...base, accounts: [], error: "still down" }]), + ).toBe(true); + }); +}); + +describe("isUsageLimitsCommand", () => { + it("recognizes only the standalone local action", () => { + expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); + expect(isUsageLimitsCommand("/usage-limits explain")).toBe(false); + expect(isUsageLimitsCommand("Explain /usage-limits")).toBe(false); + expect(isUsageLimitsCommand("/usage")).toBe(false); + }); +}); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index e7582b1ecc64..a792b20d6fd3 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -7,6 +7,9 @@ */ import { type EnvironmentId, + type UsageLimitsReport, + type ProviderInstanceId, + type ServerProviderSlashCommand, isProviderAvailable, type ServerProvider, type ServerProviderUsageLimits, @@ -15,6 +18,8 @@ import { type UsageLimitSourceSnapshots, } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; + const MINUTE = 60_000; const HOUR = 60 * MINUTE; const DAY = 24 * HOUR; @@ -144,7 +149,7 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( - provider: ServerProvider, + provider: Pick, driverLabel: (driver: ServerProvider["driver"]) => string | undefined, ): string { return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); @@ -209,3 +214,142 @@ export function formatResetsIn(window: ServerProviderUsageWindow, now: number): if (resetsAt === null) return null; return resetsAt <= now ? "resets now" : `resets in ${formatDuration(resetsAt - now)}`; } + +/** Limit commands are served by T3 from the same snapshots as Usage → Limits. */ +export const USAGE_LIMITS_COMMAND = { + name: "usage-limits", + description: "Show this provider's usage limits", +} satisfies ServerProviderSlashCommand; + +/** Handled by the client without sending a turn; anything with arguments stays an ordinary prompt. */ +export function isUsageLimitsCommand(prompt: string): boolean { + return prompt.trim().toLowerCase() === "/usage-limits"; +} + +/** + * Whether Limits has anything to say about this driver. A source that failed to + * read keeps no accounts, so its error counts for every driver rather than + * disappearing until the next successful refresh. + */ +export function hasProviderUsageLimits( + driver: ServerProvider["driver"], + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): boolean { + return ( + providersWithLimits(providers).some((provider) => provider.driver === driver) || + sources.some( + (source) => + source.accounts.some((account) => account.driver === driver) || + (source.error !== undefined && source.accounts.length === 0), + ) + ); +} + +/** + * The drivers a set of sources would offer the command to, where a source that + * failed to read counts for every driver. Two snapshots with the same coverage + * need no catalog republish, however much their quotas moved. + */ +export function sameUsageLimitCommandCoverage( + previous: UsageLimitSourceSnapshots, + next: UsageLimitSourceSnapshots, +): boolean { + const coverage = (sources: UsageLimitSourceSnapshots) => + new Set( + sources.flatMap((source) => + source.error !== undefined && source.accounts.length === 0 + ? ["*"] + : source.accounts.map((account) => String(account.driver)), + ), + ); + const before = coverage(previous); + const after = coverage(next); + return before.size === after.size && [...before].every((driver) => after.has(driver)); +} + +/** Advertise on workspace catalogs too, which replace the global command list. */ +export function withUsageLimitsCommands( + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, +): ServerProvider[] { + return providers.map((provider) => { + if (!hasProviderUsageLimits(provider.driver, providers, sources)) return provider; + const commands = (items: readonly ServerProviderSlashCommand[]) => [ + ...items.filter((command) => command.name !== USAGE_LIMITS_COMMAND.name), + USAGE_LIMITS_COMMAND, + ]; + return { + ...provider, + slashCommands: commands(provider.slashCommands), + ...(provider.workspaceSnapshots + ? { + workspaceSnapshots: provider.workspaceSnapshots.map((snapshot) => ({ + ...snapshot, + slashCommands: commands(snapshot.slashCommands), + })), + } + : {}), + }; + }); +} + +/** A point-in-time report; never refreshes or guesses which pooled account serves a turn. */ +export function collectProviderUsageLimits( + instanceId: ProviderInstanceId, + providers: readonly ServerProvider[], + sources: UsageLimitSourceSnapshots, + now: number, +): UsageLimitsReport | null { + const selected = providers.find((provider) => provider.instanceId === instanceId); + if (!selected || !hasProviderUsageLimits(selected.driver, providers, sources)) return null; + const native = providersWithLimits(providers).filter( + (provider) => provider.driver === selected.driver, + ); + const nativeAccounts = new Set( + native.flatMap((provider) => { + const key = accountKey(provider.driver, provider.auth.email); + return key && provider.usageLimits?.windows.length && !provider.usageLimits.unavailable + ? [key] + : []; + }), + ); + const accounts: Array = []; + const notices: string[] = []; + for (const provider of native) { + if (!provider.usageLimits) continue; + accounts.push({ + id: provider.instanceId, + driver: provider.driver, + label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, + ...(provider.auth.label ? { plan: provider.auth.label } : {}), + instanceId: provider.instanceId, + ...(provider.displayName ? { displayName: provider.displayName } : {}), + ...(provider.accentColor ? { accentColor: provider.accentColor } : {}), + ...(provider.auth.email ? { email: provider.auth.email } : {}), + limits: provider.usageLimits, + }); + } + for (const source of sources) { + const matching = source.accounts.filter((account) => account.driver === selected.driver); + for (const account of matching) { + const key = accountKey(account.driver, account.email); + if (key && nativeAccounts.has(key)) continue; + accounts.push({ + id: `${source.id}:${account.id}`, + driver: account.driver, + label: `${source.label} · ${account.id}`, + sourceLabel: "CLI Proxy", + ...(account.plan ? { plan: account.plan } : {}), + ...(account.email ? { email: account.email } : {}), + limits: account.usageLimits, + }); + } + // A source that failed to read has no accounts left to match on, so its + // error is reported to every provider rather than silently dropped. + if (source.error && (matching.length > 0 || source.accounts.length === 0)) { + notices.push(`${source.label}: ${source.error}`); + } + } + return { createdAt: DateTime.formatIso(DateTime.makeUnsafe(now)), accounts, notices }; +} From 585ce2c2ab396e4c3c5ffcfc422449c07896ddf9 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 16:18:13 -0700 Subject: [PATCH 160/320] refactor(web): test formatted timestamps instead of formatter options (#10221) --- apps/web/src/timestampFormat.test.ts | 57 +++++++++------------------- apps/web/src/timestampFormat.ts | 2 +- 2 files changed, 19 insertions(+), 40 deletions(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index 578587510db1..ca73095f7fd4 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -8,37 +8,9 @@ import { formatRelativeTimeLabel, formatShortTimestamp, getRelativeTimeState, - getTimestampFormatOptions, resolveTimestampLocale, } from "./timestampFormat"; -describe("getTimestampFormatOptions", () => { - it("omits hour12 when locale formatting is requested", () => { - expect(getTimestampFormatOptions("locale", true)).toEqual({ - hour: "numeric", - minute: "2-digit", - second: "2-digit", - }); - }); - - it("builds a 12-hour formatter with seconds when requested", () => { - expect(getTimestampFormatOptions("12-hour", true)).toEqual({ - hour: "numeric", - minute: "2-digit", - second: "2-digit", - hour12: true, - }); - }); - - it("builds a 24-hour formatter without seconds when requested", () => { - expect(getTimestampFormatOptions("24-hour", false)).toEqual({ - hour: "numeric", - minute: "2-digit", - hour12: false, - }); - }); -}); - describe("resolveTimestampLocale", () => { it("defers to the runtime default when the host reports no locale", () => { expect(resolveTimestampLocale(null)).toBeUndefined(); @@ -57,19 +29,26 @@ describe("resolveTimestampLocale", () => { expect(resolveTimestampLocale("not a locale")).toBeUndefined(); expect(resolveTimestampLocale("en_GB")).toBeUndefined(); }); +}); - it("renders the host locale's hour cycle under the locale setting", () => { - const formatAt1544 = (systemLocale: string | null) => - new Intl.DateTimeFormat(resolveTimestampLocale(systemLocale), { - ...getTimestampFormatOptions("locale", false), - timeZone: "UTC", - }) - .format(new Date("2026-04-07T15:44:00.000Z")) - // ICU separates the day period with a narrow no-break space. - .replace(/[  ]/g, " "); +describe("formatShortTimestamp", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); - expect(formatAt1544("en-GB")).toBe("15:44"); - expect(formatAt1544("en-US")).toBe("3:44 PM"); + it.each([ + ["en-GB", "15:44"], + ["en-US", "3:44 PM"], + ])("honors %s and the explicit hour-cycle settings", async (locale, localTime) => { + vi.stubGlobal("window", { desktopBridge: { getSystemLocale: () => locale } }); + vi.resetModules(); + const { formatShortTimestamp: format } = await import("./timestampFormat"); + const date = new Date(2026, 3, 7, 15, 44).toISOString(); + // ICU can separate the day period with a narrow no-break space. + expect(format(date, "locale").replace(/[  ]/g, " ")).toBe(localTime); + expect(format(date, "12-hour").replace(/[  ]/g, " ")).toMatch(/^3:44 [ap]m$/i); + expect(format(date, "24-hour")).toBe("15:44"); }); }); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index c6a9bdd29e10..0f87204efde4 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -1,6 +1,6 @@ import { type TimestampFormat } from "@t3tools/contracts/settings"; -export function getTimestampFormatOptions( +function getTimestampFormatOptions( timestampFormat: TimestampFormat, includeSeconds: boolean, ): Intl.DateTimeFormatOptions { From be53bbd85044e937c359a89b6004c3d3c38ffcf1 Mon Sep 17 00:00:00 2001 From: Chris Deeming Date: Sun, 6 Sep 2026 00:22:15 +0100 Subject: [PATCH 161/320] feat(usage): show remaining quota instead of used (#9889) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/features/usage/UsageLimitsSection.tsx | 29 +++++++++++-------- apps/web/src/components/usage/UsageLimits.tsx | 24 ++++++++------- docs/user/usage.md | 6 ++-- packages/shared/src/usageLimits.test.ts | 10 +++++++ packages/shared/src/usageLimits.ts | 11 +++++-- 5 files changed, 51 insertions(+), 29 deletions(-) diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index ca0608738fe9..460827b22649 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -18,6 +18,7 @@ import { limitsNotice, paceOf, providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; import { Alert, Pressable, View } from "react-native"; @@ -44,9 +45,10 @@ function useBarColor(driver: Driver): string | null { } /** - * One window as a bar spanning its whole duration: the fill is quota spent, - * the hairline is how far into the window the clock is. Pace sits under the - * left edge, the countdown under the right, so a row reads in one glance. + * One window as a bar spanning its whole duration: the fill is quota left, + * the hairline is how much of the window is left, so even spending keeps the + * fill on the line. Pace sits under the left edge, the countdown under the + * right, so a row reads in one glance. */ function WindowRow(props: { readonly window: ServerProviderUsageWindow; @@ -54,37 +56,40 @@ function WindowRow(props: { readonly now: number; }) { const { window, now } = props; - const used = Math.round(Math.max(0, Math.min(100, window.usedPercent))); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const pace = paceOf(window, now); const resetsIn = formatResetsIn(window, now); return ( {window.label} - {used}% + + {remaining}% left + = 90 + remaining <= 10 ? "h-full rounded-full bg-red-500" - : used >= 70 + : remaining <= 30 ? "h-full rounded-full bg-amber-500" : "h-full rounded-full bg-foreground" } style={[ - { flex: used }, - used < 70 && props.color ? { backgroundColor: props.color } : null, + { flex: remaining }, + remaining > 30 && props.color ? { backgroundColor: props.color } : null, ]} /> - + - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null} diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 5584cfc169f5..1a72af35c909 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -20,6 +20,7 @@ import { type LimitPace, paceOf, providerLimitsLabel, + remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; import { Fragment, useState } from "react"; @@ -95,14 +96,16 @@ function WindowBar({ readonly now: number; }) { const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); - const used = Math.max(0, Math.min(100, window.usedPercent)); + const remaining = remainingPercent(window); const elapsed = elapsedShare(window, now); + // The fill is quota left, so the even-spending mark is the time left. + const timeLeft = elapsed === null ? null : Math.round((1 - elapsed) * 100); const resetsIn = formatResetsIn(window, now); const resetsAt = window.resetsAt ? formatUpcomingTimestamp(window.resetsAt, timestampFormat, now) : null; - const summary = `${window.label}: ${Math.round(used)}% used${ - elapsed === null ? "" : `, ${Math.round(elapsed * 100)}% of the window elapsed` + const summary = `${window.label}: ${remaining}% left${ + timeLeft === null ? "" : `, ${timeLeft}% of the window left` }${resetsIn ? `, ${resetsIn}` : ""}`; return ( @@ -118,27 +121,26 @@ function WindowBar({ } >
- {used > 0 ? ( + {remaining > 0 ? (
) : null} - {elapsed !== null ? ( + {timeLeft !== null ? ( ) : null}
- {Math.round(used)}% used - {elapsed !== null ? ` · ${Math.round(elapsed * 100)}% of the window elapsed` : ""} + {remaining}% left{timeLeft !== null ? ` · ${timeLeft}% of the window left` : ""} - {elapsed !== null ? ( + {timeLeft !== null ? ( The line is where even spending would be. ) : null} {resetsAt ? ( @@ -185,7 +187,7 @@ export function LimitWindows({ {window.label} - {Math.round(window.usedPercent)}% + {remainingPercent(window)}% left diff --git a/docs/user/usage.md b/docs/user/usage.md index dc92b3f65917..4e4196a46337 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,9 +39,9 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows quota use and reset times for Codex and Claude subscriptions. It also -compares quota consumed with time elapsed in each window, so you can judge your pace before the -next reset. +**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and +Claude subscriptions. For windows with timing data, each bar also marks how much of the window is +left, so you can judge your pace before the next reset. If a window looks stale, refresh Limits to re-check every provider and hub. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index 6b1952199dd2..fede8813e913 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -20,6 +20,7 @@ import { limitsNotice, paceOf, providersWithLimits, + remainingPercent, } from "./usageLimits.ts"; const now = Date.parse("2026-09-03T12:00:00.000Z"); @@ -493,6 +494,15 @@ describe("sameUsageLimitCommandCoverage", () => { }); }); +describe("remainingPercent", () => { + it("inverts and clamps the reported usage", () => { + expect(remainingPercent(window)).toBe(60); + expect(remainingPercent({ ...window, usedPercent: 0 })).toBe(100); + expect(remainingPercent({ ...window, usedPercent: 100 })).toBe(0); + expect(remainingPercent({ ...window, usedPercent: 33.4 })).toBe(67); + }); +}); + describe("isUsageLimitsCommand", () => { it("recognizes only the standalone local action", () => { expect(isUsageLimitsCommand(" /USAGE-LIMITS\n")).toBe(true); diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index a792b20d6fd3..5cb5303320bd 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -166,6 +166,11 @@ export function limitsNotice(limits: ServerProviderUsageLimits): string | null { return limits.windows.length === 0 ? "No limits reported." : null; } +/** Quota left in the window, 0..100. Bars and labels show what remains, as Codex does. */ +export function remainingPercent(window: ServerProviderUsageWindow): number { + return Math.round(100 - Math.max(0, Math.min(100, window.usedPercent))); +} + function resetMillis(window: ServerProviderUsageWindow): number | null { if (window.resetsAt === undefined) return null; const at = Date.parse(window.resetsAt); @@ -184,9 +189,9 @@ export function elapsedShare(window: ServerProviderUsageWindow, now: number): nu export type LimitPace = "ahead" | "on" | "under"; /** - * Usage against the clock. The bar is the whole window, so the elapsed share - * is also where even spending would have put the fill; within five points of - * it counts as on pace. + * Usage against the clock. Spending evenly leaves the same share of quota as + * there is time left in the window; within five points of that counts as on + * pace, further ahead means the window may run dry first. */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); From f12d39359f0f76a64ff2d77959c5baf821df15be Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 6 Sep 2026 01:26:02 +0200 Subject: [PATCH 162/320] fix(ui): unify loading and refresh feedback across clients (#9561) Co-authored-by: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> --- .../src/features/usage/UsageRouteScreen.tsx | 25 +-- apps/mobile/src/state/usage.ts | 35 ++-- .../BranchToolbarBranchSelector.tsx | 5 +- apps/web/src/components/DiffPanel.tsx | 6 +- apps/web/src/components/LegacySidebar.tsx | 4 +- apps/web/src/components/chat/ChatComposer.tsx | 8 +- .../chat/ComposerActivityStatus.tsx | 8 +- .../chat/ComposerServerUpdateStatus.tsx | 12 +- .../components/clerk/ClerkUserProfilePage.tsx | 5 +- .../src/components/files/FileBreadcrumbs.tsx | 10 +- .../src/components/files/FileBrowserPanel.tsx | 6 +- .../src/components/files/FilePreviewPanel.tsx | 11 +- .../components/onboarding/FirstRunGate.tsx | 4 +- .../components/preview/PreviewChromeRow.tsx | 4 +- .../PullRequestActivityUnavailableState.tsx | 4 +- .../pullRequest/PullRequestDetailPanel.tsx | 25 ++- .../pullRequest/PullRequestListEmptyState.tsx | 7 +- .../pullRequest/PullRequestListFilters.tsx | 4 +- .../PullRequestsUnavailableState.tsx | 15 +- .../pullRequest/pullRequestPresentation.tsx | 6 +- .../search/ProjectContentSearchDialog.tsx | 5 +- .../settings/DiagnosticsSettings.tsx | 4 +- .../settings/ProviderInstanceCard.tsx | 5 +- .../settings/ProviderSettingsPanel.tsx | 5 +- .../settings/ResourceTelemetryDiagnostics.tsx | 11 +- .../components/settings/SettingsPanels.tsx | 5 +- .../settings/SourceControlSettings.tsx | 7 +- .../settings/ThemeSearchSection.tsx | 11 +- .../sidebar/DesktopUpdateStatusIcon.tsx | 10 +- .../sidebar/SidebarProviderUpdatePill.tsx | 5 +- apps/web/src/components/ui/refresh-icon.tsx | 20 ++ apps/web/src/components/ui/spinner.tsx | 10 +- apps/web/src/components/ui/toast.tsx | 7 +- apps/web/src/components/usage/UsagePage.tsx | 51 +++-- apps/web/src/routes/_chat.index.tsx | 5 +- apps/web/src/routes/_chat.pull-requests.tsx | 14 +- apps/web/src/state/usage.ts | 35 ++-- packages/client-runtime/package.json | 4 + .../client-runtime/src/state/runtime.test.ts | 18 ++ packages/client-runtime/src/state/runtime.ts | 8 +- .../client-runtime/src/state/usage.test.ts | 184 ++++++++++++++++++ packages/client-runtime/src/state/usage.ts | 61 ++++++ 42 files changed, 506 insertions(+), 183 deletions(-) create mode 100644 apps/web/src/components/ui/refresh-icon.tsx create mode 100644 packages/client-runtime/src/state/usage.test.ts create mode 100644 packages/client-runtime/src/state/usage.ts diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 17841cbd74fa..164b09556511 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -11,7 +11,7 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -91,10 +91,8 @@ export function UsageRouteScreen() { [isPast24Hours, merged.daily, merged.hourly], ); - // The pull spinner tracks re-scans of environments that have answered - // before. The initial scan renders its own placeholder, and an unreachable - // environment stays pending forever — neither may pin the spinner on. - const refreshingUsage = environments.some((entry) => entry.isPending && entry.summary !== null); + const [refreshingUsage, setRefreshingUsage] = useState(false); + const refreshingRef = useRef(false); const showingLimits = tab === "limits"; const selectWindow = (days: number) => { setWindowSelection({ @@ -103,17 +101,22 @@ export function UsageRouteScreen() { }); }; const refreshWindow = () => { + if (refreshingRef.current) return; const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setRefreshingUsage(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setRefreshingUsage(false); + }); }; return ( diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index f5bdc0d0858b..8686a37e2c9c 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -16,7 +16,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -72,7 +72,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } export function useUsage(input: UsageSummaryInput): UsageView { @@ -98,26 +98,17 @@ export function useUsage(input: UsageSummaryInput): UsageView { const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so pull-to-refresh always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of environments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [environments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: environments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [environments, windowKey], + ); const merged = useMemo(() => { const answered: EnvironmentUsage[] = environments.flatMap((environment) => diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 27bf2ede9b9a..f6bdd64756d3 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { isAtomCommandInterrupted, @@ -5,7 +6,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem, EnvironmentId, VcsRef, ThreadId } from "@t3tools/contracts"; import { LegendList, type LegendListRef } from "@legendapp/list/react"; -import { ChevronDownIcon, GitBranchIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { ChevronDownIcon, GitBranchIcon, SearchIcon } from "lucide-react"; import { useCallback, useDeferredValue, @@ -864,7 +865,7 @@ export function BranchToolbarBranchSelector({ className="flex cursor-pointer items-center justify-between gap-3 border-t border-border/60 px-3 py-2 text-xs" > - } > - + {branchDiffPreview.isPending ? "Refreshing diff…" : "Refresh diff"} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 872fa1e4ec60..bacdfa62118c 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import { ArchiveIcon, ArrowUpDownIcon, @@ -6,7 +7,6 @@ import { ContainerIcon, FolderPlusIcon, Globe2Icon, - LoaderIcon, SearchIcon, SquarePenIcon, TerminalIcon, @@ -2648,7 +2648,7 @@ function LocalSecondaryStatus() { variant="default" className="rounded-2xl border-border/40 bg-accent/40 text-muted-foreground" > - + Connecting {connecting.join(", ")} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 9772efc3290e..0a54a2a55abb 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import type { ApprovalRequestId, AssistantCitation, @@ -789,7 +790,6 @@ import { LockIcon, LockOpenIcon, PenLineIcon, - RotateCcwIcon, SparklesIcon, XIcon, } from "lucide-react"; @@ -5273,7 +5273,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) /> } > - + } > - + } > - + - - + + - ); + return ; } if (status === "failed") { return ; diff --git a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx index 00f20e53fbe1..2ddf8f56dd2a 100644 --- a/apps/web/src/components/clerk/ClerkUserProfilePage.tsx +++ b/apps/web/src/components/clerk/ClerkUserProfilePage.tsx @@ -1,4 +1,5 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; + import type { ReactNode } from "react"; import { cn } from "../../lib/utils"; @@ -55,7 +56,7 @@ export function ClerkUserProfileRefreshButton({ disabled={disabled || isPending} onClick={onClick} > -
) : (
- +
); } @@ -252,7 +253,7 @@ function AttachmentBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -308,7 +309,7 @@ function WorkspaceBrowserPreview(props: { if (assetUrl._tag !== "Success") { return (
- +
); } @@ -1262,7 +1263,7 @@ export default function FilePreviewPanel({
) : relativePath && file.data === null ? (
- +
) : relativePath && file.data ? ( isMarkdown && renderMarkdown ? ( diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx index a9df5cb00b59..6debb0376bd9 100644 --- a/apps/web/src/components/onboarding/FirstRunGate.tsx +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -1,7 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { useLocation, useNavigate } from "@tanstack/react-router"; import { Atom } from "effect/unstable/reactivity"; -import { RotateCcwIcon } from "lucide-react"; import { useEffect, useLayoutEffect, useState } from "react"; import { @@ -235,7 +235,7 @@ function FirstRunRecovery({ } }} > - + {settingsReadFailed ? "Retry" : "Reload"}
diff --git a/apps/web/src/components/preview/PreviewChromeRow.tsx b/apps/web/src/components/preview/PreviewChromeRow.tsx index 8dbf9f0904f0..7ca0c496a04b 100644 --- a/apps/web/src/components/preview/PreviewChromeRow.tsx +++ b/apps/web/src/components/preview/PreviewChromeRow.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ArrowLeft, ArrowRight, @@ -5,7 +6,6 @@ import { ExternalLink, MousePointerClick, PictureInPicture2, - RotateCw, } from "lucide-react"; import { type FormEvent, @@ -166,7 +166,7 @@ export function PreviewChromeRow({ /> } > - + {loading ? "Loading…" : "Refresh"} diff --git a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx index d87dfa45b0ff..2aa1413438ee 100644 --- a/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestActivityUnavailableState.tsx @@ -1,4 +1,4 @@ -import { RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { cn } from "~/lib/utils"; @@ -23,7 +23,7 @@ export function PullRequestActivityUnavailableState({

Could not load pull request activity

{error}

diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 76e50b60003b..e4aa17b383c8 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { @@ -36,7 +37,6 @@ import { PanelRightIcon, PencilIcon, PlayIcon, - RefreshCwIcon, RotateCcwIcon, TriangleAlertIcon, } from "lucide-react"; @@ -728,10 +728,16 @@ export function PullRequestDetailPanel({ // invalidation goes first so the re-reads miss that cache; if it fails, the reads still run // and at worst answer from it. const invalidate = useAtomCommand(pullRequestEnvironment.invalidate, { reportFailure: false }); + const [isInvalidating, setIsInvalidating] = useState(false); const refreshFromHost = useCallback(async () => { - await invalidate({ environmentId, input: { reference } }); - refreshDetail(); - setRefreshToken((token) => token + 1); + setIsInvalidating(true); + try { + await invalidate({ environmentId, input: { reference } }); + refreshDetail(); + setRefreshToken((token) => token + 1); + } finally { + setIsInvalidating(false); + } }, [environmentId, invalidate, reference, refreshDetail]); // A refresh asked for by the page: the detail, and through the token below, the diff with it. const appliedForcedToken = useRef(forcedRefreshToken); @@ -1670,8 +1676,14 @@ export function PullRequestDetailPanel({ - void refreshFromHost()}> - + void refreshFromHost()} + > + Refresh @@ -2315,6 +2327,7 @@ export function PullRequestDetailPanel({ {detailQuery.error && !detail ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx index 4dd92dbf2243..8c5f862700bc 100644 --- a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; /** * What the list shows when it has no rows to show. * @@ -11,7 +12,7 @@ * with no project to read from — leave the button out, since pressing it could only repeat what * is already happening or ask nobody. */ -import { PlusIcon, RefreshCwIcon, SearchIcon } from "lucide-react"; +import { PlusIcon, SearchIcon } from "lucide-react"; import { openCommandPalette } from "../../commandPaletteBus"; import { Button } from "../ui/button"; @@ -149,7 +150,7 @@ export function PullRequestListEmptyState({ {/* The hosts answered this query once; a pull request opened since then would answer differently, and nothing on screen says which of the two the reader is looking at. */} @@ -175,7 +176,7 @@ export function PullRequestListEmptyState({ ) : null} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index f77e2b845082..1c703bce3e2b 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { EnvironmentId, ProjectId, @@ -17,7 +18,6 @@ import { GitPullRequestDraftIcon, LayersIcon, ListFilterIcon, - LoaderIcon, SearchIcon, TagIcon, UserRoundIcon, @@ -120,7 +120,7 @@ export function PullRequestSearchInput({ return ( - {busy ? : } + {busy ? : } void; + refreshing?: boolean; gitHubUrl?: string; }) { return ( @@ -35,8 +38,14 @@ export function PullRequestsUnavailableState({ {onRetry || gitHubUrl ? ( {onRetry ? ( - ) : null} diff --git a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx index 8611ddc28dde..3c41e0956fed 100644 --- a/apps/web/src/components/pullRequest/pullRequestPresentation.tsx +++ b/apps/web/src/components/pullRequest/pullRequestPresentation.tsx @@ -1,3 +1,4 @@ +import { Spinner } from "~/components/ui/spinner"; import type { PullRequestActor, PullRequestCheck, @@ -15,7 +16,6 @@ import { GitPullRequestClosedIcon, GitPullRequestDraftIcon, GitPullRequestIcon, - LoaderIcon, TriangleAlertIcon, } from "lucide-react"; import { Children, isValidElement, type ReactNode } from "react"; @@ -119,7 +119,7 @@ export function PullRequestStateGlyph({ } const CHECK_STATUS_PRESENTATION = { - pending: { label: "Running", Icon: LoaderIcon, toneClassName: "animate-spin text-amber-500" }, + pending: { label: "Running", Icon: Spinner, toneClassName: "text-amber-500" }, "action-required": { label: "Awaiting action", Icon: CircleDotIcon, @@ -136,7 +136,7 @@ const CHECK_STATUS_PRESENTATION = { neutral: { label: "Neutral", Icon: CircleDashedIcon, toneClassName: "text-muted-foreground/70" }, } as const satisfies Record< PullRequestCheckStatus, - { label: string; Icon: typeof CircleCheckIcon; toneClassName: string } + { label: string; Icon: typeof CircleCheckIcon | typeof Spinner; toneClassName: string } >; function isWorkflowApprovalCheck(check: Pick): boolean { diff --git a/apps/web/src/components/search/ProjectContentSearchDialog.tsx b/apps/web/src/components/search/ProjectContentSearchDialog.tsx index 6be17ed33243..26015c5b3393 100644 --- a/apps/web/src/components/search/ProjectContentSearchDialog.tsx +++ b/apps/web/src/components/search/ProjectContentSearchDialog.tsx @@ -1,5 +1,6 @@ +import { Spinner } from "~/components/ui/spinner"; import type { ProjectContentMatch } from "@t3tools/contracts"; -import { LoaderCircle } from "lucide-react"; + import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"; import { useActiveProjectTarget, type ActiveProjectTarget } from "~/hooks/useActiveProjectTarget"; @@ -225,7 +226,7 @@ function OpenContentSearchDialog(props: {
{search.isPending ? ( - Searching… + Searching… ) : search.error ? ( {search.error} diff --git a/apps/web/src/components/settings/DiagnosticsSettings.tsx b/apps/web/src/components/settings/DiagnosticsSettings.tsx index 0b23fb2d2072..ec3bf854d7b0 100644 --- a/apps/web/src/components/settings/DiagnosticsSettings.tsx +++ b/apps/web/src/components/settings/DiagnosticsSettings.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { AlertTriangleIcon, ChevronDownIcon, @@ -5,7 +6,6 @@ import { CopyIcon, FolderOpenIcon, InfoIcon, - RefreshCwIcon, } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { @@ -766,7 +766,7 @@ function DiagnosticsRefreshButton({ onClick={onClick} aria-label={label} > - + } /> diff --git a/apps/web/src/components/settings/ProviderInstanceCard.tsx b/apps/web/src/components/settings/ProviderInstanceCard.tsx index b3faa7b0509f..327b48c2d44a 100644 --- a/apps/web/src/components/settings/ProviderInstanceCard.tsx +++ b/apps/web/src/components/settings/ProviderInstanceCard.tsx @@ -1,10 +1,11 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { ArrowUpCircleIcon, CopyIcon, DownloadIcon, - LoaderIcon, LockIcon, LockOpenIcon, PlusIcon, @@ -739,7 +740,7 @@ export function ProviderInstanceCard({ disabled={isUpdating} onClick={onRunUpdate} > - {isUpdating ? : } + {isUpdating ? : } {isUpdating ? "Updating" : "Update now"} ) : null} diff --git a/apps/web/src/components/settings/ProviderSettingsPanel.tsx b/apps/web/src/components/settings/ProviderSettingsPanel.tsx index fb4620b054c1..74676e3ff167 100644 --- a/apps/web/src/components/settings/ProviderSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProviderSettingsPanel.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -24,7 +25,7 @@ import * as Arr from "effect/Array"; import * as Duration from "effect/Duration"; import * as Equal from "effect/Equal"; import * as Result from "effect/Result"; -import { PlusIcon, RefreshCwIcon } from "lucide-react"; +import { PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; @@ -993,7 +994,7 @@ export function EnvironmentProviderSettings({ aria-busy={isRefreshingProviders} onClick={() => void refreshProviders()} > - + Refresh provider status {isRefreshingProviders ? ( diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ca934952f8a9..003e46869a91 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -1,3 +1,4 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { ActivityIcon, AlertTriangleIcon, @@ -9,8 +10,6 @@ import { GaugeIcon, HardDriveIcon, MemoryStickIcon, - RefreshCwIcon, - RotateCcwIcon, } from "lucide-react"; import type { BackgroundBooleanState, @@ -982,9 +981,7 @@ export function ResourceTelemetryDiagnostics() { onClick={telemetry.refresh} aria-label="Refresh resource telemetry" > - + } /> @@ -1095,7 +1092,7 @@ export function ResourceTelemetryDiagnostics() { headerAction={ collectorNeedsRetry ? ( ) : null @@ -1233,7 +1230,7 @@ export function ResourceTelemetryDiagnostics() { onClick={history.refresh} aria-label="Refresh resource history" > - +
} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index e79464d1757c..fe782c5757de 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -1,4 +1,5 @@ -import { ArchiveIcon, ArchiveX, ChevronRightIcon, LoaderIcon, SettingsIcon } from "lucide-react"; +import { Spinner } from "~/components/ui/spinner"; +import { ArchiveIcon, ArchiveX, ChevronRightIcon, SettingsIcon } from "lucide-react"; import { Link, useNavigate } from "@tanstack/react-router"; import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -3029,7 +3030,7 @@ export function ArchivedThreadsPanel() { title={ {isLoadingArchive ? ( - + ) : ( )} diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index ee1fa66a3db4..736b7f1b4b99 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -1,4 +1,5 @@ -import { ChevronDownIcon, GitPullRequestIcon, RefreshCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ChevronDownIcon, GitPullRequestIcon } from "lucide-react"; import * as Duration from "effect/Duration"; import * as Option from "effect/Option"; import { useEffect, useState, type ReactNode } from "react"; @@ -487,7 +488,7 @@ function EmptySourceControlDiscovery({ @@ -532,7 +533,7 @@ export function SourceControlSettingsPanel() { disabled={discovery.isPending} aria-label="Rescan server environment" > - + } /> diff --git a/apps/web/src/components/settings/ThemeSearchSection.tsx b/apps/web/src/components/settings/ThemeSearchSection.tsx index b270bf7b8e4d..eb6620a136fb 100644 --- a/apps/web/src/components/settings/ThemeSearchSection.tsx +++ b/apps/web/src/components/settings/ThemeSearchSection.tsx @@ -1,10 +1,5 @@ -import { - ExternalLinkIcon, - PackagePlusIcon, - PaletteIcon, - RefreshCwIcon, - SearchIcon, -} from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { ExternalLinkIcon, PackagePlusIcon, PaletteIcon, SearchIcon } from "lucide-react"; import { useCallback, useEffect, useRef, useState } from "react"; import { importOpenVsxThemeExtension, @@ -417,7 +412,7 @@ export function ThemeSearchSection({ {isInstalling ? ( ) : isInstalled ? ( - + ) : ( )} diff --git a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx index 60fa379ce30e..833559e1cc27 100644 --- a/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx +++ b/apps/web/src/components/sidebar/DesktopUpdateStatusIcon.tsx @@ -1,8 +1,7 @@ -import { CheckIcon, DownloadIcon, RefreshCwIcon, RotateCwIcon } from "lucide-react"; +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { CheckIcon, DownloadIcon, RotateCwIcon } from "lucide-react"; import type { AnimationEventHandler } from "react"; -import { cn } from "../../lib/utils"; - const DOWNLOAD_PROGRESS_RADIUS = 14; const DOWNLOAD_PROGRESS_CIRCUMFERENCE = 2 * Math.PI * DOWNLOAD_PROGRESS_RADIUS; @@ -118,8 +117,9 @@ export function DesktopUpdateStatusIcon({ if (status === "downloaded") return ; return ( - ); diff --git a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx index 84dd7f4b5634..066b7e583253 100644 --- a/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarProviderUpdatePill.tsx @@ -1,7 +1,8 @@ +import { Spinner } from "~/components/ui/spinner"; import { useNavigate } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; import type { ServerProvider } from "@t3tools/contracts"; -import { CircleCheckIcon, DownloadIcon, LoaderIcon, TriangleAlertIcon, XIcon } from "lucide-react"; +import { CircleCheckIcon, DownloadIcon, TriangleAlertIcon, XIcon } from "lucide-react"; import { useCallback, useEffect, useState, type CSSProperties } from "react"; import { primaryServerProvidersAtom } from "../../state/server"; @@ -173,7 +174,7 @@ export function SidebarProviderUpdatePill() { onClick={openProviderSettings} > {displayedView.tone === "loading" ? ( - + ) : displayedView.tone === "success" ? ( ) : displayedView.tone === "error" ? ( diff --git a/apps/web/src/components/ui/refresh-icon.tsx b/apps/web/src/components/ui/refresh-icon.tsx new file mode 100644 index 000000000000..6fbddeb365a6 --- /dev/null +++ b/apps/web/src/components/ui/refresh-icon.tsx @@ -0,0 +1,20 @@ +import { RefreshCwIcon } from "lucide-react"; + +import { cn } from "~/lib/utils"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; + +/** Keep the refresh glyph in place while its owning action is running. */ +export function RefreshIcon({ + refreshing = false, + className, + ...props +}: React.ComponentPropsWithoutRef & { refreshing?: boolean }) { + return ( + + ); +} diff --git a/apps/web/src/components/ui/spinner.tsx b/apps/web/src/components/ui/spinner.tsx index 362b78f95463..44f0ffd50816 100644 --- a/apps/web/src/components/ui/spinner.tsx +++ b/apps/web/src/components/ui/spinner.tsx @@ -1,11 +1,13 @@ -import { Loader2Icon } from "lucide-react"; +import { LoaderCircleIcon } from "lucide-react"; +import { observeVisibleAnimation } from "~/lib/visibleAnimation"; import { cn } from "~/lib/utils"; -function Spinner({ className, ...props }: React.ComponentProps) { +function Spinner({ className, ...props }: React.ComponentPropsWithoutRef) { return ( - diff --git a/apps/web/src/components/ui/toast.tsx b/apps/web/src/components/ui/toast.tsx index 69fd0ebf3664..0f6483c2ae67 100644 --- a/apps/web/src/components/ui/toast.tsx +++ b/apps/web/src/components/ui/toast.tsx @@ -1,5 +1,7 @@ "use client"; +import { Spinner } from "~/components/ui/spinner"; + import { Toast } from "@base-ui/react/toast"; import { useEffect, @@ -20,7 +22,6 @@ import { CircleCheckIcon, CopyIcon, InfoIcon, - LoaderCircleIcon, TriangleAlertIcon, XIcon, } from "lucide-react"; @@ -83,7 +84,7 @@ const threadToastVisibleTimeoutRemainingMs = new Map(); const TOAST_ICONS = { error: CircleAlertIcon, info: InfoIcon, - loading: LoaderCircleIcon, + loading: Spinner, success: CircleCheckIcon, warning: TriangleAlertIcon, } as const; @@ -357,7 +358,7 @@ function ToastBodyContent({ className="[&>svg]:h-lh [&>svg]:w-4 [&_svg]:pointer-events-none [&_svg]:shrink-0" data-slot="toast-icon" > - +
) : null}
("cost"); const showingLimits = metric === "limits"; + const [isRefreshing, setIsRefreshing] = useState(false); + const refreshingRef = useRef(false); const [breakdown, setBreakdown] = useState<"model" | "time">("model"); const [selectedEnvironmentIds, setSelectedEnvironmentIds] = useState | null>(null); @@ -138,26 +140,39 @@ export function UsagePage() { }); }; const refreshWindow = () => { + if (refreshingRef.current) return; + if (showingLimits) { - for (const [environmentId, presentation] of presentations) { - if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) continue; - if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { - void refreshProviders({ environmentId, input: {} }); - } - } + refreshingRef.current = true; + setIsRefreshing(true); + void Promise.all( + Array.from(presentations, ([environmentId, presentation]) => { + if (selectedEnvironmentIds !== null && !selectedEnvironmentIds.has(environmentId)) return; + if (presentation.connection.phase === "connected" && presentation.serverConfig !== null) { + return refreshProviders({ environmentId, input: {} }); + } + }), + ).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); return; } const nextWindow = makeWindow(windowDays, undefined, isPast24Hours ? "hour" : "day"); if ( - nextWindow.sinceDay === window.sinceDay && - nextWindow.untilDay === window.untilDay && - nextWindow.sinceTime === window.sinceTime && - nextWindow.untilTime === window.untilTime + nextWindow.sinceDay !== window.sinceDay || + nextWindow.untilDay !== window.untilDay || + nextWindow.sinceTime !== window.sinceTime || + nextWindow.untilTime !== window.untilTime ) { - refresh(); - } else { setWindowSelection({ days: windowDays, window: nextWindow }); } + refreshingRef.current = true; + setIsRefreshing(true); + void refresh(nextWindow).finally(() => { + refreshingRef.current = false; + setIsRefreshing(false); + }); }; const windowLabel = isPast24Hours && window.sinceTime !== undefined && window.untilTime !== undefined @@ -225,10 +240,12 @@ export function UsagePage() {
@@ -282,10 +299,12 @@ export function UsagePage() {
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 6a53ee024548..339c7eae223b 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,6 +1,7 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { LinkIcon, PlusIcon, RotateCcwIcon } from "lucide-react"; +import { LinkIcon, PlusIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { openCommandPalette } from "../commandPaletteBus"; @@ -96,7 +97,7 @@ function DraftStartError({ onRetry }: { readonly onRetry: () => void }) {
diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 37522ea3b0a2..7aab91d1b82a 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -1,3 +1,5 @@ +import { RefreshIcon } from "~/components/ui/refresh-icon"; +import { Spinner } from "~/components/ui/spinner"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestHostOf, resolveEnvironmentMachineKind, ThreadId } from "@t3tools/contracts"; import type { @@ -26,10 +28,8 @@ import { LayersIcon, ListChecksIcon, PenLineIcon, - LoaderIcon, Maximize2Icon, Minimize2Icon, - RefreshCwIcon, SearchIcon, } from "lucide-react"; import { @@ -1581,7 +1581,11 @@ function PullRequestsRouteView() { ) : firstLoad ? ( ) : listQuery.error && entries.length === 0 ? ( - listQuery.refresh()} /> + listQuery.refresh()} + /> ) : carriedToNothing ? ( ) : entries.length === 0 ? ( @@ -1658,7 +1662,7 @@ function PullRequestsRouteView() {
{loadingMore ? ( - + {sentCursors === null ? "Updating pull requests" : "Loading more"} ) : canContinue || pageSize < MAX_PAGE_SIZE ? ( @@ -2318,7 +2322,7 @@ function PullRequestRefreshControl({ onClick={onRefresh} disabled={refreshing} > - + ); } diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index be65400c9800..617ac93e4b7c 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -13,7 +13,7 @@ import { type UsageSummary, type UsageSummaryInput, } from "@t3tools/contracts"; -import { runAtomCommand } from "@t3tools/client-runtime/state/runtime"; +import { refreshUsage } from "@t3tools/client-runtime/state/usage"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; @@ -70,7 +70,7 @@ export interface UsageView { * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; - readonly refresh: () => void; + readonly refresh: (input?: UsageSummaryInput) => Promise; } export function useUsage( @@ -108,26 +108,17 @@ export function useUsage( [environments, selectedEnvironmentIds], ); - // Refreshing only the derived atom would re-read the per-environment SWR - // queries within their stale window and change nothing. Refresh each - // environment's query so the button always rescans. - // - // Each environment refetches model pricing first, so a model released since - // its last daily fetch gets priced by the rescan. The rescan runs whether or - // not the refetch succeeds: an offline environment still recounts tokens. - const refresh = useCallback(() => { - const input = JSON.parse(windowKey) as UsageSummaryInput; - for (const environment of selectedEnvironments) { - const { environmentId } = environment; - const query = serverEnvironment.usageSummary({ environmentId, input }); - void runAtomCommand( - appAtomRegistry, - serverEnvironment.refreshUsageRates, - { environmentId, input: {} }, - { reportFailure: false }, - ).finally(() => appAtomRegistry.refresh(query)); - } - }, [selectedEnvironments, windowKey]); + const refresh = useCallback( + (nextInput?: UsageSummaryInput) => + refreshUsage({ + registry: appAtomRegistry, + server: serverEnvironment, + presentations: environmentPresentations, + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), + input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), + }), + [selectedEnvironments, windowKey], + ); const merged = useMemo(() => { const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 68f5bad51474..c7d921393444 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -147,6 +147,10 @@ "types": "./src/state/runtime.ts", "default": "./src/state/runtime.ts" }, + "./state/usage": { + "types": "./src/state/usage.ts", + "default": "./src/state/usage.ts" + }, "./state/server": { "types": "./src/state/server.ts", "default": "./src/state/server.ts" diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index 3f12f44d5f4c..ca4ac5ac911d 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -683,6 +683,24 @@ describe("executeAtomQuery", () => { registry.dispose(); }); + + it("settles when its caller aborts a waiting query", async () => { + const registry = AtomRegistry.make(); + const controller = new AbortController(); + const resultPromise = executeAtomQuery(registry, Atom.make(Effect.never), { + reportDefect: false, + signal: controller.signal, + }); + + controller.abort(); + + const result = await resultPromise; + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(Cause.hasInterruptsOnly(result.cause)).toBe(true); + } + registry.dispose(); + }); }); describe("runtime command runner", () => { diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index affd5aa90ec1..3e61909ee711 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -336,6 +336,8 @@ export interface AtomQueryOptions extends AtomCommandOptions { * verification flows where a cached failure must not satisfy a retry. */ readonly refresh?: boolean; + /** Interrupt the query wait when its caller no longer wants the result. */ + readonly signal?: AbortSignal; } export async function executeAtomQuery( @@ -362,7 +364,11 @@ export async function executeAtomQuery( }); }), ); - return executeAtomCommand(() => Effect.runPromiseExit(query), options, reporter); + return executeAtomCommand( + () => Effect.runPromiseExit(query, { signal: options.signal }), + options, + reporter, + ); } export function createRuntimeCommand( diff --git a/packages/client-runtime/src/state/usage.test.ts b/packages/client-runtime/src/state/usage.test.ts new file mode 100644 index 000000000000..29f029c9d863 --- /dev/null +++ b/packages/client-runtime/src/state/usage.test.ts @@ -0,0 +1,184 @@ +import { + EnvironmentId, + UsageDay, + USAGE_CONTRACT_VERSION, + type UsageSummary, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import type { EnvironmentPresentation } from "../connection/presentation.ts"; +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import { refreshUsage } from "./usage.ts"; + +const input = { + sinceDay: UsageDay.make("2026-09-05"), + untilDay: UsageDay.make("2026-09-05"), + timeZone: "UTC", +}; +const pricing = { status: "fresh" as const, source: "test", fetchedAt: null, knownModels: 1 }; +const summary: UsageSummary = { + ...input, + contractVersion: USAGE_CONTRACT_VERSION, + readAt: "2026-09-05T12:00:00Z", + buckets: [], + sources: [], + pricing, + scanDurationMs: 1, +}; +const registries: AtomRegistry.AtomRegistry[] = []; +afterEach(() => { + for (const registry of registries.splice(0)) registry.dispose(); +}); + +function harness(ids = ["a"]) { + const registry = AtomRegistry.make(); + registries.push(registry); + const environments = ids.map((id) => { + const environmentId = EnvironmentId.make(id); + const rates = Promise.withResolvers< + AsyncResult.Success | AsyncResult.Failure + >(); + const scan = Promise.withResolvers(); + const scanStarted = Promise.withResolvers(); + const presentation = Atom.make({ + connection: { phase: "connected" }, + } as EnvironmentPresentation | null); + const query = Atom.make( + Effect.promise(() => { + scanStarted.resolve(); + return scan.promise; + }), + ); + return { environmentId, rates, scan, scanStarted, presentation, query }; + }); + function get(environmentId: EnvironmentId) { + const environment = environments.find((entry) => entry.environmentId === environmentId); + if (!environment) throw new Error(`Unknown environment: ${environmentId}`); + return environment; + } + const options = { + registry, + environmentIds: environments.map((entry) => entry.environmentId), + input, + server: { + usageSummary: ({ environmentId }: { environmentId: EnvironmentId }) => + get(environmentId).query, + refreshUsageRates: { + label: "test:rates", + run: ( + _registry: AtomRegistry.AtomRegistry, + { environmentId }: { environmentId: EnvironmentId }, + ) => get(environmentId).rates.promise, + }, + }, + presentations: { + presentationAtom: (environmentId: EnvironmentId) => get(environmentId).presentation, + }, + } satisfies Parameters[0]; + return { registry, environments, refresh: () => refreshUsage(options) }; +} + +describe("manual usage refresh", () => { + it.each(["success", "failure"])("waits for the rescan after a pricing %s", async (result) => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + expect(finished).toBe(false); + entry.rates.resolve( + result === "success" + ? AsyncResult.success(pricing) + : AsyncResult.fail(new Error("Pricing offline")), + ); + await entry.scanStarted.promise; + expect(finished).toBe(false); + entry.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when an environment disconnects during the rescan", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await entry.scanStarted.promise; + registry.set(entry.presentation, null); + await refreshing; + }); + + it("waits for healthy environments without waiting for a recovering environment", async () => { + const { registry, environments, refresh } = harness(["healthy", "recovering"]); + const [healthy, recovering] = environments; + registry.set(recovering!.presentation, null); + let finished = false; + const refreshing = refresh().then(() => { + finished = true; + }); + for (const entry of environments) entry.rates.resolve(AsyncResult.success(pricing)); + await healthy!.scanStarted.promise; + expect(finished).toBe(false); + healthy!.scan.resolve(summary); + await refreshing; + expect(finished).toBe(true); + }); + + it("settles when connected state has no usable RPC session", async () => { + const { + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + const refreshing = refresh(); + entry.rates.resolve( + AsyncResult.fail( + new EnvironmentRpcUnavailableError({ + environmentId: entry.environmentId, + message: "No session", + }), + ), + ); + await refreshing; + }); + + it("replaces a scan that started before pricing was refreshed", async () => { + const { + registry, + environments: [environment], + refresh, + } = harness(); + const entry = environment!; + let reads = 0; + const rescanned = Promise.withResolvers(); + const query = Atom.make( + Effect.promise(() => { + reads += 1; + if (reads > 1) { + rescanned.resolve(); + return Promise.resolve(summary); + } + return new Promise(() => {}); + }), + ); + entry.query = query; + const unmount = registry.mount(query); + expect(reads).toBe(1); + const refreshing = refresh(); + entry.rates.resolve(AsyncResult.success(pricing)); + await rescanned.promise; + await refreshing; + expect(reads).toBe(2); + unmount(); + }); +}); diff --git a/packages/client-runtime/src/state/usage.ts b/packages/client-runtime/src/state/usage.ts new file mode 100644 index 000000000000..10a565a0c24f --- /dev/null +++ b/packages/client-runtime/src/state/usage.ts @@ -0,0 +1,61 @@ +import type { EnvironmentId, UsageSummaryInput } from "@t3tools/contracts"; +import * as Schema from "effect/Schema"; +import type { AtomRegistry } from "effect/unstable/reactivity"; + +import { EnvironmentRpcUnavailableError } from "../rpc/client.ts"; +import type { createEnvironmentPresentationAtoms } from "./presentation.ts"; +import { executeAtomQuery, runAtomCommand, squashAtomCommandFailure } from "./runtime.ts"; +import type { createServerEnvironmentAtoms } from "./server.ts"; + +const isEnvironmentRpcUnavailable = Schema.is(EnvironmentRpcUnavailableError); + +/** Refresh pricing, then await each selected environment's rescan while it remains connected. */ +export async function refreshUsage({ + registry, + server, + presentations, + environmentIds, + input, +}: { + registry: AtomRegistry.AtomRegistry; + server: Pick< + ReturnType, + "usageSummary" | "refreshUsageRates" + >; + presentations: Pick, "presentationAtom">; + environmentIds: readonly EnvironmentId[]; + input: UsageSummaryInput; +}): Promise { + await Promise.all( + environmentIds.map(async (environmentId) => { + const query = server.usageSummary({ environmentId, input }); + const presentation = presentations.presentationAtom(environmentId); + const controller = new AbortController(); + const abortWhenDisconnected = () => { + if (registry.get(presentation)?.connection.phase !== "connected") controller.abort(); + }; + const unsubscribe = registry.subscribe(presentation, abortWhenDisconnected); + abortWhenDisconnected(); + try { + const ratesResult = await runAtomCommand( + registry, + server.refreshUsageRates, + { environmentId, input: {} }, + { reportFailure: false }, + ); + const sessionUnavailable = + ratesResult._tag === "Failure" && + isEnvironmentRpcUnavailable(squashAtomCommandFailure(ratesResult)); + // Invalidate even on failure so reconnects cannot reuse the old summary. + registry.refresh(query); + if (sessionUnavailable || controller.signal.aborted) return; + await executeAtomQuery(registry, query, { + reportFailure: false, + signal: controller.signal, + }); + } finally { + unsubscribe(); + } + }), + ); +} From bd16b86d50c1df49afeb7c0a7568a4908ade4048 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 17:18:50 -0700 Subject: [PATCH 163/320] fix(client-runtime): report terminated thread loads (#10216) --- .../client-runtime/src/rpc/client.test.ts | 98 ++++- packages/client-runtime/src/rpc/client.ts | 95 +++-- .../src/state/threads-atoms.test.ts | 338 +++++++++++++++++- packages/client-runtime/src/state/threads.ts | 34 +- 4 files changed, 498 insertions(+), 67 deletions(-) diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index f2141add930f..9e4e8a600d55 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -465,36 +465,112 @@ describe("environment RPC", () => { }), ); - it.effect("does not classify subscription defects as expected failures", () => + it.effect.each(["input", "stream"] as const)( + "does not classify %s subscription defects as expected failures", + (where) => + Effect.gen(function* () { + const defect = new Error("subscription invariant failed"); + let expectedFailureCount = 0; + let inputs = 0; + let streams = 0; + const observedDefects: unknown[] = []; + const client = { + [WS_METHODS.subscribeTerminalEvents]: () => { + streams += 1; + return where === "stream" ? Stream.die(defect) : Stream.never; + }, + } as unknown as WsRpcProtocolClient; + const { activeSession, supervisor } = yield* makeHarness(); + + yield* SubscriptionRef.set(activeSession, Option.some(session(client))); + const exit = yield* subscribeDynamicWithSession( + WS_METHODS.subscribeTerminalEvents, + () => + Effect.sync(() => { + inputs += 1; + }).pipe(Effect.andThen(where === "input" ? Effect.die(defect) : Effect.succeed({}))), + { + onDefect: (cause) => + Effect.sync(() => { + observedDefects.push(Cause.squash(cause)); + }), + onExpectedFailure: () => + Effect.sync(() => { + expectedFailureCount += 1; + }), + retryExpectedFailureAfter: "250 millis", + }, + ).pipe( + Stream.runDrain, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + Effect.exit, + ); + + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); + } + expect(inputs).toBe(1); + expect(streams).toBe(where === "input" ? 0 : 1); + expect(expectedFailureCount).toBe(0); + expect(observedDefects).toEqual([defect]); + }), + ); + + it.effect("reports an initializer defect once after an expected failure retries", () => Effect.gen(function* () { - const defect = new Error("subscription invariant failed"); - let expectedFailureCount = 0; + const defect = new Error("Synthetic retry initializer defect"); + const expectedFailure = yield* Deferred.make(); + const observations: string[] = []; + const observedDefects: unknown[] = []; + let inputs = 0; const client = { - [WS_METHODS.subscribeTerminalEvents]: () => Stream.die(defect), + [WS_METHODS.subscribeTerminalEvents]: () => { + observations.push("stream"); + return Stream.fail(new Error("subscription not ready")); + }, } as unknown as WsRpcProtocolClient; const { activeSession, supervisor } = yield* makeHarness(); - yield* SubscriptionRef.set(activeSession, Option.some(session(client))); - const exit = yield* subscribe( + const fiber = yield* subscribeDynamicWithSession( WS_METHODS.subscribeTerminalEvents, - {}, + () => + Effect.sync(() => { + inputs += 1; + observations.push(`input ${inputs}`); + return inputs; + }).pipe( + Effect.flatMap((attempt) => (attempt === 1 ? Effect.succeed({}) : Effect.die(defect))), + ), { - onExpectedFailure: () => + onDefect: (cause) => Effect.sync(() => { - expectedFailureCount += 1; + observations.push("defect"); + observedDefects.push(Cause.squash(cause)); }), + onExpectedFailure: () => + Effect.sync(() => { + observations.push("expected failure"); + }).pipe(Effect.andThen(Deferred.succeed(expectedFailure, undefined)), Effect.asVoid), + retryExpectedFailureAfter: "250 millis", }, ).pipe( Stream.runDrain, Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), Effect.exit, + Effect.forkChild, ); - + yield* Deferred.await(expectedFailure); + yield* TestClock.adjust("250 millis"); + const exit = yield* Fiber.join(fiber); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.hasDies(exit.cause)).toBe(true); + expect(Cause.squash(exit.cause)).toBe(defect); } - expect(expectedFailureCount).toBe(0); + expect(observations).toEqual(["input 1", "stream", "expected failure", "input 2", "defect"]); + expect(observedDefects).toEqual([defect]); }), ); }); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index e7c5117954cb..bc13d429ac96 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -171,6 +171,10 @@ export function runStream( } interface SubscriptionOptions { + /** Reports protocol or programming defects without changing their recovery policy. */ + readonly onDefect?: ( + cause: Cause.Cause>, + ) => Effect.Effect; readonly onExpectedFailure?: ( cause: Cause.Cause>, ) => Effect.Effect; @@ -228,47 +232,60 @@ function subscribeDynamicMapped( }); return mapStream(session, method(input)).pipe( Stream.ensuring(completeObservation), - Stream.catchCause((cause) => { - const hasOnlyExpectedFailures = - cause.reasons.length > 0 && - cause.reasons.every((reason) => reason._tag === "Fail"); - const isTransportFailure = - hasOnlyExpectedFailures && - cause.reasons.every( - (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), - ); - if (isTransportFailure) { - return Stream.fromEffect( - Effect.logWarning( - "Durable RPC subscription lost its transport; waiting for the next session.", - { - cause: Cause.pretty(cause), - method: tag, - environmentId: supervisor.target.environmentId, - }, - ), - ).pipe(Stream.drain); - } - if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { - const handled = Stream.fromEffect( - options.onExpectedFailure(cause), - ).pipe(Stream.drain); - if (options.retryExpectedFailureAfter === undefined) { - return handled; - } - return handled.pipe( - Stream.concat( - Stream.fromEffect( - Effect.sleep(options.retryExpectedFailureAfter), - ).pipe(Stream.drain), - ), - Stream.concat(subscribeToSession()), - ); - } - return Stream.failCause(cause); - }), ); }), + ).pipe( + Stream.tapCause((cause) => + options?.onDefect !== undefined && + cause.reasons.some( + (reason) => + reason._tag === "Die" || + (reason._tag === "Fail" && + isRpcClientError(reason.error) && + reason.error.reason._tag === "RpcClientDefect"), + ) + ? options.onDefect(cause) + : Effect.void, + ), + Stream.catchCause((cause) => { + const hasOnlyExpectedFailures = + cause.reasons.length > 0 && + cause.reasons.every((reason) => reason._tag === "Fail"); + const isTransportFailure = + hasOnlyExpectedFailures && + cause.reasons.every( + (reason) => reason._tag === "Fail" && isRpcClientError(reason.error), + ); + if (isTransportFailure) { + return Stream.fromEffect( + Effect.logWarning( + "Durable RPC subscription lost its transport; waiting for the next session.", + { + cause: Cause.pretty(cause), + method: tag, + environmentId: supervisor.target.environmentId, + }, + ), + ).pipe(Stream.drain); + } + if (hasOnlyExpectedFailures && options?.onExpectedFailure !== undefined) { + const handled = Stream.fromEffect(options.onExpectedFailure(cause)).pipe( + Stream.drain, + ); + if (options.retryExpectedFailureAfter === undefined) { + return handled; + } + return handled.pipe( + Stream.concat( + Stream.fromEffect(Effect.sleep(options.retryExpectedFailureAfter)).pipe( + Stream.drain, + ), + ), + Stream.concat(subscribeToSession()), + ); + } + return Stream.failCause(cause); + }), ), ); return subscribeToSession(); diff --git a/packages/client-runtime/src/state/threads-atoms.test.ts b/packages/client-runtime/src/state/threads-atoms.test.ts index 27229a7aff61..54b6f9e73e97 100644 --- a/packages/client-runtime/src/state/threads-atoms.test.ts +++ b/packages/client-runtime/src/state/threads-atoms.test.ts @@ -11,6 +11,8 @@ import { type OrchestrationThreadStreamItem, } from "@t3tools/contracts"; import { afterEach, describe, expect, it, vi } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -18,7 +20,10 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; +import * as TestClock from "effect/testing/TestClock"; import { Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { RpcClientError } from "effect/unstable/rpc"; +import { Socket } from "effect/unstable/socket"; import type { ConnectionCatalogEntry } from "../connection/catalog.ts"; import { EnvironmentRegistry } from "../connection/registry.ts"; @@ -30,6 +35,7 @@ import { type SupervisorConnectionState, } from "../connection/model.ts"; import { EnvironmentSupervisor } from "../connection/supervisor.ts"; +import { ConnectionWakeups, type ConnectionWakeup } from "../connection/wakeups.ts"; import { EnvironmentCacheStore } from "../platform/persistence.ts"; import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import type { RpcSession } from "../rpc/session.ts"; @@ -38,6 +44,7 @@ import { THREAD_SNAPSHOT_IDLE_TTL_MS } from "./threadRetention.ts"; import type { ThreadSnapshotWindow } from "./threadSnapshotHttp.ts"; import { createEnvironmentThreadStateAtoms, + makeEnvironmentThreadState, requestOlderThreadTurns, ThreadSnapshotLoader, type EnvironmentThreadState, @@ -54,7 +61,7 @@ const THREAD: OrchestrationThread = { id: THREAD_ID, projectId: ProjectId.make("project-1"), title: "Cached thread", - modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "ModelA" }, runtimeMode: "full-access", interactionMode: "default", branch: "main", @@ -86,10 +93,15 @@ const CONNECTED_STATE: SupervisorConnectionState = { const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options?: { readonly snapshot?: OrchestrationThreadDetailSnapshot; readonly connected?: boolean; + readonly httpNone?: boolean; + readonly initialLoad?: Effect.Effect>; + readonly stream?: Stream.Stream; }) { + const clock = yield* Clock.Clock; + const wakeups = yield* Queue.unbounded(); const subscriptions = yield* Queue.unbounded<{ readonly afterSequence: number | undefined; - readonly events: Queue.Queue; + readonly events: Queue.Queue; readonly closed: Deferred.Deferred; }>(); const olderLoads = yield* Queue.unbounded<{ @@ -106,7 +118,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? [ORCHESTRATION_WS_METHODS.subscribeThread]: (input: { readonly afterSequence?: number }) => Stream.unwrap( Effect.gen(function* () { - const events = yield* Queue.unbounded(); + const events = yield* Queue.unbounded(); const closed = yield* Deferred.make(); yield* Effect.acquireRelease( Effect.sync(() => { @@ -119,7 +131,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }).pipe(Effect.andThen(Deferred.succeed(closed, undefined))), ); yield* Queue.offer(subscriptions, { afterSequence: input.afterSequence, events, closed }); - return Stream.fromQueue(events); + return options?.stream ?? Stream.fromQueue(events); }), ), } as unknown as WsRpcProtocolClient; @@ -179,6 +191,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? }); const runtime = Atom.runtime( Layer.mergeAll( + Layer.succeed(Clock.Clock, clock), + Layer.succeed(ConnectionWakeups, { changes: Stream.fromQueue(wakeups) }), Layer.succeed(EnvironmentRegistry, environmentRegistry), Layer.succeed( EnvironmentCacheStore, @@ -208,8 +222,12 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? if (window?.beforeCursor === undefined) { return Effect.sync(() => { httpLoads += 1; - return Option.some(snapshot); - }); + }).pipe( + Effect.andThen( + options?.initialLoad ?? + Effect.succeed(options?.httpNone ? Option.none() : Option.some(snapshot)), + ), + ); } return Effect.gen(function* () { const response = @@ -235,6 +253,8 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? const registry = yield* makeRegistry; return { + runtime, + supervisor, registry, makeRegistry, rawAtoms: raw, @@ -246,6 +266,7 @@ const makeHarness = Effect.fn("TestThreadAtoms.makeHarness")(function* (options? connectionState, session, sessionRef, + wakeups, counts: () => ({ httpLoads, diskLoads, opened, active }), }; }); @@ -275,6 +296,311 @@ describe("createEnvironmentThreadStateAtoms", () => { vi.restoreAllMocks(); }); + it.effect("exposes snapshot loader defects before the RPC subscription starts", () => + Effect.gen(function* () { + const completed = yield* Deferred.make(); + const h = yield* makeHarness({ + connected: true, + initialLoad: Effect.die( + new Error("SYNTHETIC_RAW_SNAPSHOT_DEFECT_SHOULD_NOT_REACH_THREAD_UI"), + ).pipe(Effect.ensuring(Deferred.succeed(completed, undefined))), + }); + const unmount = h.registry.mount(h.stateAtom); + yield* Deferred.await(completed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.status).toBe("empty"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(Option.none()); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + expect(h.counts()).toEqual({ httpLoads: 1, diskLoads: 1, opened: 0, active: 0 }); + unmount(); + }), + ); + + it.effect.each([ + { kind: "protocol", httpNone: true }, + { kind: "protocol", httpNone: false }, + { kind: "fatal", httpNone: true }, + { kind: "fatal", httpNone: false }, + ] as const)( + "retains a terminated $kind load diagnostic across connection updates (empty: $httpNone)", + ({ kind, httpNone }) => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + const error = new Error("SYNTHETIC_RAW_DEFECT_SHOULD_NOT_REACH_THREAD_UI"); + yield* Queue.failCause( + first.events, + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ), + ); + yield* Deferred.await(first.closed); + // The real finalizer has run; advancing the atom runtime's test clock + // also verifies that a defect does not enter the domain retry loop. + yield* TestClock.adjust("1 second"); + const failed = h.registry.get(h.stateAtom); + expect(failed.status).toBe(httpNone ? "empty" : "cached"); + expect(failed.error).toEqual(Option.some("Could not synchronize the thread.")); + expect(failed.data).toEqual(httpNone ? Option.none() : Option.some(THREAD)); + expect(h.counts().opened).toBe(1); + expect(h.counts().active).toBe(0); + + // Session publication can precede connected, and a fatal child cannot + // restart just because its supervisor reconnects. + for (const connection of [ + AVAILABLE_CONNECTION_STATE, + { ...CONNECTED_STATE, phase: "connecting" as const }, + CONNECTED_STATE, + ]) { + yield* SubscriptionRef.set(h.connectionState, connection); + yield* TestClock.adjust("0 millis"); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + } + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + if (kind === "fatal") { + yield* TestClock.adjust("1 second"); + expect(h.counts().opened).toBe(1); + expect(h.registry.get(h.stateAtom)).toEqual(failed); + unmount(); + return; + } + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + expect(h.registry.get(h.stateAtom).status).toBe("synchronizing"); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + expect(recovered.data).toEqual(Option.some(THREAD)); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retries a protocol failure on foreground without replacing the session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "incompatible snapshot", + cause: new Error("incompatible snapshot"), + }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("0 millis"); + expect(Option.isSome(h.registry.get(h.stateAtom).error)).toBe(true); + yield* Queue.offer(h.wakeups, "application-active"); + const next = yield* Queue.take(h.subscriptions); + expect(h.registry.get(h.stateAtom).error).toEqual(Option.none()); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("keeps transport loss nonterminal and recovers with a replacement session", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail( + first.events, + new RpcClientError.RpcClientError({ + reason: new Socket.SocketCloseError({ code: 1006, closeReason: "connection lost" }), + }), + ); + yield* Deferred.await(first.closed); + yield* TestClock.adjust("1 second"); + expect(h.registry.get(h.stateAtom)).toMatchObject({ + status: "synchronizing", + error: Option.none(), + data: Option.none(), + }); + expect(h.counts().opened).toBe(1); + yield* SubscriptionRef.set(h.sessionRef, Option.some({ ...h.session })); + const next = yield* Queue.take(h.subscriptions); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + yield* observeState(h.registry, h.stateAtom, (state) => state.status === "live"); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect("retains ordinary domain error reporting and same-session retries", () => + Effect.gen(function* () { + const h = yield* makeHarness({ connected: true, httpNone: true }); + const unmount = h.registry.mount(h.stateAtom); + const first = yield* Queue.take(h.subscriptions); + yield* Queue.fail(first.events, new Error("thread not found yet")); + yield* Deferred.await(first.closed); + const failed = yield* observeState(h.registry, h.stateAtom, (state) => + Option.isSome(state.error), + ); + expect(failed.error).toEqual(Option.some("thread not found yet")); + yield* TestClock.adjust("250 millis"); + const next = yield* Queue.take(h.subscriptions); + expect(h.counts().opened).toBe(2); + yield* Queue.offer(next.events, { kind: "snapshot", snapshot: SNAPSHOT }); + yield* Queue.offer(next.events, { kind: "synchronized" }); + const recovered = yield* observeState( + h.registry, + h.stateAtom, + (state) => state.status === "live", + ); + expect(recovered.error).toEqual(Option.none()); + unmount(); + yield* Deferred.await(next.closed); + }), + ); + + it.effect.each([ + { kind: "protocol", deleted: false }, + { kind: "fatal", deleted: false }, + { kind: "domain", deleted: false }, + { kind: "protocol", deleted: true }, + ] as const)( + "keeps buffered outcomes after a $kind failure (deleted: $deleted)", + ({ kind, deleted }) => + Effect.gen(function* () { + const burst = yield* Deferred.make(); + const error = new Error( + kind === "domain" + ? "buffered thread failure" + : "SYNTHETIC_BUFFERED_DEFECT_SHOULD_NOT_REACH_THREAD_UI", + ); + const items: OrchestrationThreadStreamItem[] = [ + { kind: "snapshot", snapshot: SNAPSHOT }, + { kind: "synchronized" }, + { + kind: "event", + event: { + eventId: EventId.make("buffered-event"), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + sequence: 8, + occurredAt: THREAD.createdAt, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.meta-updated", + payload: { + threadId: THREAD_ID, + title: "Buffer drained", + updatedAt: THREAD.createdAt, + }, + }, + }, + ]; + if (deleted) { + items.push({ + kind: "event", + event: { + eventId: EventId.make("buffered-deletion"), + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + sequence: 9, + occurredAt: THREAD.createdAt, + aggregateKind: "thread", + aggregateId: THREAD_ID, + type: "thread.deleted", + payload: { threadId: THREAD_ID, deletedAt: THREAD.createdAt }, + }, + }); + } + const failure = + kind === "fatal" + ? Cause.die(error) + : Cause.fail( + kind === "domain" + ? error + : new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: error.message, + cause: error, + }), + }), + ); + const h = yield* makeHarness({ + connected: true, + httpNone: true, + stream: Stream.fromEffect(Deferred.await(burst)).pipe( + Stream.flatMap(() => Stream.fromIterable(items)), + Stream.concat(Stream.failCause(failure)), + ), + }); + yield* Effect.gen(function* () { + const state = yield* makeEnvironmentThreadState(THREAD_ID); + const initial = yield* Deferred.make(); + const drained = yield* Deferred.make(); + yield* SubscriptionRef.changes(state).pipe( + Stream.runForEach((value) => + Deferred.succeed(initial, undefined).pipe( + Effect.andThen( + ( + deleted + ? value.status === "deleted" + : Option.getOrNull(value.data)?.title === "Buffer drained" + ) + ? Deferred.succeed(drained, undefined) + : Effect.void, + ), + ), + ), + Effect.forkScoped, + ); + yield* Deferred.await(initial); + const subscription = yield* Queue.take(h.subscriptions); + yield* Deferred.succeed(burst, undefined); + yield* Deferred.await(subscription.closed); + yield* Deferred.await(drained); + const final = yield* SubscriptionRef.get(state); + if (deleted) { + expect(final.status).toBe("deleted"); + expect(final.data).toEqual(Option.none()); + expect(final.error).toEqual(Option.none()); + return; + } + expect(Option.getOrThrow(final.data).title).toBe("Buffer drained"); + expect(final.error).toEqual( + Option.some(kind === "domain" ? error.message : "Could not synchronize the thread."), + ); + expect(final.status).toBe("cached"); + }).pipe( + Effect.provideService(EnvironmentSupervisor, h.supervisor), + Effect.provide(h.registry.get(h.runtime.layer)), + Effect.scoped, + ); + }), + ); + it.effect("shares one live stream and closes it after the last detail consumer leaves", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index 1311469c0ee0..83b85bf02f09 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -310,8 +310,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Effect.forkScoped, ); - const setSynchronizing = SubscriptionRef.update(state, (current) => - current.status === "deleted" + const setConnecting = SubscriptionRef.update(state, (current) => + current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -320,7 +320,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }, ); const setReady = SubscriptionRef.update(state, (current) => - current.status === "live" || current.status === "deleted" + current.status === "live" || current.status === "deleted" || Option.isSome(current.error) ? current : { ...current, @@ -341,14 +341,14 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), })); }); - const setStreamError = (cause: Cause.Cause) => + const setStreamError = (message: string) => Ref.set(awaitingCompletion, false).pipe( Effect.andThen( SubscriptionRef.update(state, (current) => ({ ...current, status: current.status === "deleted" ? current.status : statusWithoutLiveData(current.data), - error: Option.some(formatThreadError(cause)), + error: Option.some(message), })), ), ); @@ -362,8 +362,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const waiting = yield* Ref.get(awaitingCompletion); yield* SubscriptionRef.update(state, (current) => ({ data: Option.some(thread), - status: waiting ? ("synchronizing" as const) : ("live" as const), - error: Option.none(), + // Buffered values from the failed attempt can still arrive after its error. + status: Option.isSome(current.error) + ? ("cached" as const) + : waiting + ? ("synchronizing" as const) + : ("live" as const), + error: current.error, page: page === "keep" ? current.page : page, })); // Active threads can update many times per second and retain large tool @@ -423,7 +428,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (item.kind === "synchronized") { yield* Ref.set(awaitingCompletion, false); yield* SubscriptionRef.update(state, (current) => - Option.isSome(current.data) && current.status !== "deleted" + Option.isSome(current.data) && current.status !== "deleted" && Option.isNone(current.error) ? { ...current, status: "live" as const, error: Option.none() } : current, ); @@ -639,7 +644,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make Stream.runForEach((connectionState) => { switch (connectionProjectionPhase(connectionState)) { case "synchronizing": - return setSynchronizing; + return setConnecting; case "disconnected": return setDisconnected; case "ready": @@ -661,7 +666,13 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const resumingLive = yield* Ref.make(initialState.status === "live"); const markSynchronizing = Effect.gen(function* () { if (yield* Ref.get(resumingLive)) return; - yield* setSynchronizing; + // Connection notifications do not establish that a terminated load restarted. + // Clear its diagnostic only when this subscription actually tries again. + yield* SubscriptionRef.update(state, (current) => + current.status === "deleted" + ? current + : { ...current, status: "synchronizing" as const, error: Option.none() }, + ); }); yield* markSynchronizing; @@ -757,7 +768,8 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make }; }), { - onExpectedFailure: setStreamError, + onDefect: () => setStreamError("Could not synchronize the thread."), + onExpectedFailure: (cause) => setStreamError(formatThreadError(cause)), retryExpectedFailureAfter: "250 millis", resubscribe: foregroundResubscriptions, }, From 050690d1bc048c1f94096c17a1c9231c3ad61a83 Mon Sep 17 00:00:00 2001 From: maria Date: Sat, 5 Sep 2026 21:29:41 -0400 Subject: [PATCH 164/320] fix(server): settle threads using actual pull request terminal timestamps (#9934) --- apps/server/src/git/GitManager.test.ts | 19 ++++++-- apps/server/src/git/GitManager.ts | 18 +++++++- .../ThreadSettlementPolicy.test.ts | 43 +++++++++++++------ .../orchestration/ThreadSettlementPolicy.ts | 9 ++-- .../ThreadSettlementReactor.test.ts | 23 ++++++++-- .../orchestration/ThreadSettlementReactor.ts | 5 ++- .../AzureDevOpsPullRequestProvider.ts | 2 + .../BitbucketPullRequestProvider.ts | 4 +- .../pullRequest/GitHubPullRequestCli.test.ts | 8 +++- .../src/pullRequest/GitHubPullRequestCli.ts | 4 ++ .../src/pullRequest/PullRequestProvider.ts | 4 ++ .../src/pullRequest/PullRequestService.ts | 4 ++ .../src/sourceControl/AzureDevOpsCli.test.ts | 2 + .../AzureDevOpsSourceControlProvider.test.ts | 7 ++- .../AzureDevOpsSourceControlProvider.ts | 4 ++ .../src/sourceControl/GitHubCli.test.ts | 10 ++++- apps/server/src/sourceControl/GitHubCli.ts | 6 ++- .../GitHubSourceControlProvider.test.ts | 6 ++- .../GitHubSourceControlProvider.ts | 4 +- .../src/sourceControl/GitLabCli.test.ts | 10 ++++- apps/server/src/sourceControl/GitLabCli.ts | 2 + .../GitLabSourceControlProvider.test.ts | 7 ++- .../GitLabSourceControlProvider.ts | 2 + .../sourceControl/azureDevOpsPullRequests.ts | 11 ++++- .../src/sourceControl/gitHubPullRequests.ts | 5 +++ .../src/sourceControl/gitLabMergeRequests.ts | 6 +++ .../pullRequest/PullRequestDetailPanel.tsx | 8 ++++ packages/contracts/src/git.ts | 8 ++-- packages/contracts/src/pullRequest.ts | 2 + packages/contracts/src/sourceControl.ts | 2 + 30 files changed, 197 insertions(+), 48 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 643e1e7a0d5b..b8f4090453be 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -511,7 +511,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as unknown[]), @@ -555,7 +555,7 @@ function createGitHubCliWithFakeGh(scenario: FakeGhScenario = {}): { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => JSON.parse(result.stdout) as GitHubCli.GitHubPullRequestSummary), @@ -1148,6 +1148,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "open", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-03T15:00:00.000Z", }); expect((yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim()).toBe("main"); @@ -1179,6 +1181,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { baseRefName: "develop", headRefName: "main", state: "MERGED", + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00Z", }, ]), @@ -1190,6 +1193,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: "2026-04-07T15:00:00Z", updatedAt: "2026-04-08T15:00:00.000Z", }); }), @@ -1241,6 +1246,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-04T15:00:00.000Z", }); expect(ghCalls.some((call) => call.includes("--head feature/deleted-local-branch"))).toBe( @@ -1305,6 +1312,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-04-05T15:00:00.000Z", }); expect( @@ -1691,7 +1700,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -1757,7 +1766,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-03-10T07:00:00.000Z", }); expect(ghCalls).toContain( - "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "pr list --head contributor:main --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ); }), 20_000, @@ -2142,6 +2151,8 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(pullRequest).toEqual({ state: "merged", + closedAt: null, + mergedAt: null, updatedAt: "2026-05-02T10:00:00.000Z", }); }), diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 2d8af0c9e8bb..76f2ebc6b510 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -100,7 +100,12 @@ export class GitManager extends Context.Service< readonly cwd: string; readonly branch: string; }) => Effect.Effect< - { readonly state: "open" | "closed" | "merged"; readonly updatedAt: string | null } | null, + { + readonly state: "open" | "closed" | "merged"; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + } | null, GitManagerServiceError >; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; @@ -171,6 +176,8 @@ interface OpenPrInfo { interface PullRequestInfo extends OpenPrInfo, PullRequestHeadRemoteInfo { state: "open" | "closed" | "merged"; isDraft?: boolean; + closedAt?: string | null; + mergedAt?: string | null; updatedAt: Option.Option; } @@ -406,6 +413,8 @@ function toPullRequestInfo(summary: ChangeRequest): PullRequestInfo { headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } @@ -2157,7 +2166,12 @@ export const make = Effect.gen(function* () { return null; } const statusPr = toStatusPr(latest); - return { state: statusPr.state, updatedAt: statusPr.updatedAt }; + return { + state: statusPr.state, + updatedAt: statusPr.updatedAt, + closedAt: latest.closedAt ?? null, + mergedAt: latest.mergedAt ?? null, + }; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( "invalidateLocalStatus", diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts index 05fb16203e0b..61c512e6fd91 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.test.ts @@ -6,7 +6,7 @@ import { TurnId, type OrchestrationThreadShell, } from "@t3tools/contracts"; -import { resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; +import { type SettlementPullRequest, resolveAutoSettlementAt } from "./ThreadSettlementPolicy.ts"; const NOW = "2026-08-28T12:00:00.000Z"; const makeThread = ( @@ -36,7 +36,7 @@ const makeThread = ( const decide = ( thread: OrchestrationThreadShell, - pullRequest: { state: "open" | "closed" | "merged"; updatedAt: string | null } | null = null, + pullRequest: SettlementPullRequest | null = null, settings: { days?: number | null; merge?: boolean } = {}, ) => resolveAutoSettlementAt({ @@ -77,7 +77,7 @@ describe("resolveAutoSettlementAt", () => { latestTurn: null, updatedAt: "2026-08-27T00:00:00.000Z", }), - pullRequest: { state: "closed", updatedAt: NOW }, + pullRequest: { state: "closed", closedAt: NOW }, now: NOW, autoSettleAfterDays: null, autoSettleOnMerge: true, @@ -100,10 +100,10 @@ describe("resolveAutoSettlementAt", () => { }); 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: "closed", closedAt: NOW }, { merge: false })).toBe(true); + expect(decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false })).toBe(true); expect( - decide(makeThread(), { state: "merged", updatedAt: NOW }, { merge: false, days: null }), + decide(makeThread(), { state: "merged", mergedAt: NOW }, { merge: false, days: null }), ).toBe(false); }); @@ -111,17 +111,36 @@ describe("resolveAutoSettlementAt", () => { expect( decide( makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), - { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" }, + { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" }, { days: null }, ), ).toBe(false); }); + it.each(["closed", "merged"] as const)( + "ignores metadata edits after resumed work for %s requests", + (state) => { + expect( + decide( + makeThread({ latestUserMessageAt: "2026-08-27T00:00:00.000Z" }), + { + state, + closedAt: "2026-08-26T00:00:00.000Z", + mergedAt: "2026-08-26T00:00:00.000Z", + updatedAt: NOW, + }, + { days: null }, + ), + ).toBe(false); + expect(decide(makeThread(), { state, updatedAt: NOW }, { 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" }, + { state: "closed", closedAt: "2026-08-19T00:00:00.000Z" }, { days: null }, ), ).toBe(false); @@ -129,9 +148,9 @@ describe("resolveAutoSettlementAt", () => { 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); + expect(decide(recentThread, { state: "closed", closedAt: null })).toBe(false); + expect(decide(recentThread, { state: "merged", mergedAt: "unknown" })).toBe(false); + expect(decide(makeThread(), { state: "closed", closedAt: null })).toBe(true); }); it("uses user request time instead of completion time as the PR anchor", () => { @@ -145,7 +164,7 @@ describe("resolveAutoSettlementAt", () => { assistantMessageId: null, }, }); - expect(decide(thread, { state: "merged", updatedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); + expect(decide(thread, { state: "merged", mergedAt: "2026-08-26T00:00:00.000Z" })).toBe(true); }); it("blocks pins, snooze, pending work, live sessions, and queued starts", () => { diff --git a/apps/server/src/orchestration/ThreadSettlementPolicy.ts b/apps/server/src/orchestration/ThreadSettlementPolicy.ts index eac5a960a482..7c55fa37d1f3 100644 --- a/apps/server/src/orchestration/ThreadSettlementPolicy.ts +++ b/apps/server/src/orchestration/ThreadSettlementPolicy.ts @@ -2,7 +2,9 @@ import type { OrchestrationThreadShell } from "@t3tools/contracts"; export interface SettlementPullRequest { readonly state: "open" | "closed" | "merged"; - readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; + readonly updatedAt?: string | null; } const DAY_MS = 24 * 60 * 60 * 1_000; @@ -49,14 +51,15 @@ function pullRequestSettles( if (pullRequest.state !== "closed" && (pullRequest.state !== "merged" || !autoSettleOnMerge)) { return false; } - if (pullRequest.updatedAt === null) return false; + const terminalAt = pullRequest.state === "merged" ? pullRequest.mergedAt : pullRequest.closedAt; + if (terminalAt == 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 pullRequestAt = Date.parse(terminalAt); const userAnchorAt = Date.parse(userAnchor); if (Number.isNaN(pullRequestAt) || Number.isNaN(userAnchorAt)) return false; return pullRequestAt >= userAnchorAt; diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index d3b24d5f77b1..eefc18f7b461 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -127,6 +127,8 @@ function makePullRequestSummary(input: { headBranch: "feature", baseBranch: "main", updatedAt: input.updatedAt ?? NOW, + closedAt: input.state === "closed" ? (input.updatedAt ?? NOW) : null, + mergedAt: input.state === "merged" ? (input.updatedAt ?? NOW) : null, }; } @@ -372,7 +374,9 @@ describe("ThreadSettlementReactor", () => { }), ]), branchPullRequest: () => - Ref.get(pullRequest).pipe(Effect.map((state) => ({ state, updatedAt: NOW }))), + Ref.get(pullRequest).pipe( + Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), + ), }); yield* Effect.gen(function* () { @@ -472,7 +476,12 @@ describe("ThreadSettlementReactor", () => { ]), branchPullRequest: () => Ref.get(state).pipe( - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => ({ + state: pullRequestState, + updatedAt: NOW, + closedAt: NOW, + mergedAt: NOW, + })), ), onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), }); @@ -603,7 +612,12 @@ describe("ThreadSettlementReactor", () => { : Effect.void, ), Effect.andThen(Ref.get(state)), - Effect.map((pullRequestState) => ({ state: pullRequestState, updatedAt: NOW })), + Effect.map((pullRequestState) => ({ + state: pullRequestState, + updatedAt: NOW, + closedAt: NOW, + mergedAt: NOW, + })), ), }); @@ -749,7 +763,8 @@ describe("ThreadSettlementReactor", () => { makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), ], ), - branchPullRequest: () => Effect.succeed({ state: "closed", updatedAt: NOW }), + branchPullRequest: () => + Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), pullRequestSummary: (input) => Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 6539135adfe6..70de3c41d7e9 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -119,7 +119,7 @@ export const make = Effect.gen(function* () { ) { return { state: "merged", - updatedAt: mergedPullRequest.mergedAt, + mergedAt: mergedPullRequest.mergedAt, } satisfies SettlementPullRequest; } if (!projects.has(thread.linkedPullRequest.projectId)) { @@ -135,7 +135,8 @@ export const make = Effect.gen(function* () { ); return { state: summary.state, - updatedAt: summary.updatedAt, + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, } satisfies SettlementPullRequest; } if (thread.branch === null) return null; diff --git a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts index 3d501a32d61b..ae586ee0a61c 100644 --- a/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts +++ b/apps/server/src/pullRequest/AzureDevOpsPullRequestProvider.ts @@ -90,6 +90,8 @@ function toChangeRequest(pullRequest: AzureDevOpsPullRequest): ProviderChangeReq additions: 0, deletions: 0, createdAt: pullRequest.createdAt, + closedAt: pullRequest.state === "closed" ? pullRequest.closedAt : null, + mergedAt: pullRequest.state === "merged" ? pullRequest.closedAt : null, updatedAt: pullRequest.updatedAt, reviewRequestLogins: pullRequest.reviewRequestLogins, // Azure keeps labels on work items rather than on the pull request. diff --git a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts index 47d41eeee6d9..3b5b93d11c46 100644 --- a/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts +++ b/apps/server/src/pullRequest/BitbucketPullRequestProvider.ts @@ -175,8 +175,8 @@ export const make = Effect.gen(function* () { deletions: diffStat.deletions, changedFiles: diffStat.changedFiles, body: pullRequest.body, - mergedAt: pullRequest.state === "merged" ? pullRequest.updatedAt : null, - closedAt: pullRequest.state === "closed" ? pullRequest.updatedAt : null, + mergedAt: null, + closedAt: null, reviewers: pullRequest.reviewers, checks, // Bitbucket publishes no per-repository list of allowed strategies, so the ones it diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts index 1e6ca0ed43a6..f61b7c3233f6 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.test.ts @@ -192,7 +192,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", baseRefName: "main", headRefName: "feat/summary", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }), ); @@ -211,7 +213,9 @@ layer("GitHubPullRequestCli.layer", (it) => { url: "https://github.com/acme/web/pull/7", headBranch: "feat/summary", baseBranch: "main", - state: "open", + state: "merged", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: "2026-08-23T10:00:00Z", updatedAt: "2026-08-24T12:34:56.000Z", }); expect(mockedGetPullRequest).toHaveBeenCalledOnce(); diff --git a/apps/server/src/pullRequest/GitHubPullRequestCli.ts b/apps/server/src/pullRequest/GitHubPullRequestCli.ts index 5d5c2062c08c..89a50f93ced7 100644 --- a/apps/server/src/pullRequest/GitHubPullRequestCli.ts +++ b/apps/server/src/pullRequest/GitHubPullRequestCli.ts @@ -469,6 +469,8 @@ export class GitHubPullRequestCli extends Context.Service< readonly baseBranch: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; }, GitHubPullRequestCliError @@ -1652,6 +1654,8 @@ export const make = Effect.gen(function* () { baseBranch: summary.baseRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, }), ), diff --git a/apps/server/src/pullRequest/PullRequestProvider.ts b/apps/server/src/pullRequest/PullRequestProvider.ts index 22028ced5ddf..5f1aba8ba9b6 100644 --- a/apps/server/src/pullRequest/PullRequestProvider.ts +++ b/apps/server/src/pullRequest/PullRequestProvider.ts @@ -78,6 +78,8 @@ export interface ProviderChangeRequest { readonly additions: number; readonly deletions: number; readonly createdAt: string; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; /** Accounts with a review requested. Team-level requests are excluded by each provider. */ readonly reviewRequestLogins: ReadonlyArray; @@ -98,6 +100,8 @@ export interface ProviderChangeRequestSummary { readonly state: PullRequestState; /** Present when the host says an open pull request is still a draft. */ readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: string; } diff --git a/apps/server/src/pullRequest/PullRequestService.ts b/apps/server/src/pullRequest/PullRequestService.ts index a8d3dc2fdeaf..2229a4f652c0 100644 --- a/apps/server/src/pullRequest/PullRequestService.ts +++ b/apps/server/src/pullRequest/PullRequestService.ts @@ -1257,6 +1257,8 @@ export const make = Effect.gen(function* () { ...(changeRequest.isDraft === true ? { isDraft: true } : {}), headBranch: changeRequest.headBranch, baseBranch: changeRequest.baseBranch, + closedAt: changeRequest.closedAt ?? null, + mergedAt: changeRequest.mergedAt ?? null, updatedAt: changeRequest.updatedAt, })), ); @@ -2316,6 +2318,8 @@ export const make = Effect.gen(function* () { ...(detail.isDraft === true ? { isDraft: true } : {}), headBranch: detail.headBranch, baseBranch: detail.baseBranch, + closedAt: detail.closedAt, + mergedAt: detail.mergedAt, updatedAt: detail.updatedAt, }); const shouldReplaceHeldSummary = (key: string, next: PullRequestSummary) => { diff --git a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts index f0cb52003029..24b28af13fe4 100644 --- a/apps/server/src/sourceControl/AzureDevOpsCli.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsCli.test.ts @@ -163,6 +163,8 @@ describe("AzureDevOpsCli.layer", () => { }); assert.strictEqual(result[0]?.state, "merged"); + assert.strictEqual(result[0]?.mergedAt, "2026-01-03T00:00:00.000Z"); + assert.strictEqual(result[0]?.closedAt, null); expect(mockRun).toHaveBeenCalledWith({ operation: "AzureDevOpsCli.execute", command: "az", diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts index cacdd1a3cd97..8e55d453b224 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.test.ts @@ -22,7 +22,8 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", updatedAt: Option.none(), }), }); @@ -39,7 +40,9 @@ it.effect("maps Azure DevOps PR summaries into provider-neutral change requests" url: "https://dev.azure.com/acme/project/_git/repo/pullrequest/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: false, }); diff --git a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts index 8a840c524eba..20a74cc8a5d7 100644 --- a/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts +++ b/apps/server/src/sourceControl/AzureDevOpsSourceControlProvider.ts @@ -62,6 +62,8 @@ function toChangeRequest(summary: { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: ChangeRequest["updatedAt"]; }): ChangeRequest { return { @@ -73,6 +75,8 @@ function toChangeRequest(summary: { headRefName: summary.headRefName, state: summary.state, ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt, isCrossRepository: false, }; diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts index 3f08e92e2c1e..f72259b677eb 100644 --- a/apps/server/src/sourceControl/GitHubCli.test.ts +++ b/apps/server/src/sourceControl/GitHubCli.test.ts @@ -93,6 +93,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isDraft: true, updatedAt: "2026-08-24T12:34:56.000Z", isCrossRepository: true, @@ -107,7 +109,7 @@ describe("GitHubCli.layer", () => { "view", "#42", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], cwd: "/repo", timeoutMs: 30_000, @@ -154,6 +156,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-threads", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/codething-mvp", headRepositoryOwnerLogin: "octocat", @@ -207,6 +211,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "feature/pr-list", state: "open", + closedAt: null, + mergedAt: null, }, ]); }).pipe(Effect.provide(layer)), @@ -259,6 +265,8 @@ describe("GitHubCli.layer", () => { baseRefName: "main", headRefName: "t3code/codex-turn-mapping", state: "open", + closedAt: null, + mergedAt: null, isCrossRepository: false, headRepositoryNameWithOwner: "pingdotgg/codething-mvp", headRepositoryOwnerLogin: "pingdotgg", diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 49b2ea31a08c..85736a95c5dd 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -206,6 +206,8 @@ export interface GitHubPullRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: string; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -367,7 +369,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 1), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), @@ -399,7 +401,7 @@ export const make = Effect.gen(function* () { "view", input.reference, "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }).pipe( Effect.map((result) => result.stdout.trim()), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts index 7faa2fe351ef..a025ce5ec800 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.test.ts @@ -60,6 +60,8 @@ it.effect("maps GitHub PR summaries into provider-neutral change requests", () = baseRefName: "main", headRefName: "feature/source-control", state: "open", + closedAt: null, + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", @@ -125,6 +127,7 @@ it.effect("uses gh json listing for non-open change request state queries", () = baseRefName: "main", headRefName: "feature/merged", state: "merged", + mergedAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-02T00:00:00.000Z", }, ]), @@ -150,10 +153,11 @@ it.effect("uses gh json listing for non-open change request state queries", () = "--limit", "10", "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ]); assert.strictEqual(changeRequests[0]?.provider, "github"); assert.strictEqual(changeRequests[0]?.state, "merged"); + assert.strictEqual(changeRequests[0]?.mergedAt, "2026-01-01T00:00:00Z"); assert.deepStrictEqual( changeRequests[0]?.updatedAt, Option.some(DateTime.makeUnsafe("2026-01-02T00:00:00.000Z")), diff --git a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts index 1a20b587256a..74f08a9a9127 100644 --- a/apps/server/src/sourceControl/GitHubSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitHubSourceControlProvider.ts @@ -31,6 +31,8 @@ function toChangeRequest(summary: GitHubCli.GitHubPullRequestSummary): ChangeReq headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt === undefined ? Option.none() @@ -154,7 +156,7 @@ export const make = Effect.gen(function* () { "--limit", String(input.limit ?? 20), "--json", - "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", + "number,title,url,baseRefName,headRefName,state,isDraft,mergedAt,closedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner", ], }) .pipe( diff --git a/apps/server/src/sourceControl/GitLabCli.test.ts b/apps/server/src/sourceControl/GitLabCli.test.ts index eb56b434b2f8..c5f22fe3088f 100644 --- a/apps/server/src/sourceControl/GitLabCli.test.ts +++ b/apps/server/src/sourceControl/GitLabCli.test.ts @@ -46,7 +46,8 @@ layer("GitLabCli.layer", (it) => { web_url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", target_branch: "main", source_branch: "feature/mr-threads", - state: "opened", + state: "closed", + closed_at: "2026-08-23T10:00:00Z", source_project_id: 101, target_project_id: 100, source_project: { @@ -71,7 +72,9 @@ layer("GitLabCli.layer", (it) => { url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/mr-threads", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, isCrossRepository: true, headRepositoryNameWithOwner: "octocat/t3code", headRepositoryOwnerLogin: "octocat", @@ -107,6 +110,7 @@ layer("GitLabCli.layer", (it) => { target_branch: " main ", source_branch: " feature/mr-list ", state: "merged", + merged_at: "2026-08-23T11:00:00Z", }, ]), ), @@ -130,6 +134,8 @@ layer("GitLabCli.layer", (it) => { baseRefName: "main", headRefName: "feature/mr-list", state: "merged", + closedAt: null, + mergedAt: "2026-08-23T11:00:00Z", }, ]); expect(mockedRun).toHaveBeenCalledWith( diff --git a/apps/server/src/sourceControl/GitLabCli.ts b/apps/server/src/sourceControl/GitLabCli.ts index ab8dfbb5f334..9f76a6182ce4 100644 --- a/apps/server/src/sourceControl/GitLabCli.ts +++ b/apps/server/src/sourceControl/GitLabCli.ts @@ -247,6 +247,8 @@ export interface GitLabMergeRequestSummary { readonly headRefName: string; readonly state?: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt?: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts index 0d06e0665214..3cd442a6e169 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.test.ts @@ -24,7 +24,8 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", headRepositoryOwnerLogin: "fork", @@ -43,7 +44,9 @@ it.effect("maps GitLab MR summaries into provider-neutral change requests", () = url: "https://gitlab.com/pingdotgg/t3code/-/merge_requests/42", baseRefName: "main", headRefName: "feature/source-control", - state: "open", + state: "closed", + closedAt: "2026-08-23T10:00:00Z", + mergedAt: null, updatedAt: Option.none(), isCrossRepository: true, headRepositoryNameWithOwner: "fork/t3code", diff --git a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts index 2ec1f9b9a228..28211c6b8509 100644 --- a/apps/server/src/sourceControl/GitLabSourceControlProvider.ts +++ b/apps/server/src/sourceControl/GitLabSourceControlProvider.ts @@ -27,6 +27,8 @@ function toChangeRequest(summary: GitLabCli.GitLabMergeRequestSummary): ChangeRe headRefName: summary.headRefName, state: summary.state ?? "open", ...(summary.isDraft === true ? { isDraft: true } : {}), + closedAt: summary.closedAt ?? null, + mergedAt: summary.mergedAt ?? null, updatedAt: summary.updatedAt ?? Option.none(), ...(summary.isCrossRepository !== undefined ? { isCrossRepository: summary.isCrossRepository } diff --git a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts index 8ac682399e1d..24c0e49fd8f4 100644 --- a/apps/server/src/sourceControl/azureDevOpsPullRequests.ts +++ b/apps/server/src/sourceControl/azureDevOpsPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedAzureDevOpsPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; } @@ -163,14 +165,21 @@ function normalizeAzureDevOpsPullRequestUrl( function normalizeAzureDevOpsPullRequestRecord( raw: Schema.Schema.Type, ): NormalizedAzureDevOpsPullRequestRecord { + const state = normalizeAzureDevOpsPullRequestState(raw.status); + const terminalAt = Option.match(raw.closedDate ?? Option.none(), { + onNone: () => null, + onSome: DateTime.formatIso, + }); return { number: raw.pullRequestId, title: raw.title, url: normalizeAzureDevOpsPullRequestUrl(raw), baseRefName: normalizeRefName(raw.targetRefName), headRefName: normalizeRefName(raw.sourceRefName), - state: normalizeAzureDevOpsPullRequestState(raw.status), + state, ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: state === "closed" ? terminalAt : null, + mergedAt: state === "merged" ? terminalAt : null, updatedAt: (raw.closedDate ?? Option.none()).pipe( Option.orElse(() => raw.creationDate ?? Option.none()), ), diff --git a/apps/server/src/sourceControl/gitHubPullRequests.ts b/apps/server/src/sourceControl/gitHubPullRequests.ts index 9e4f282e1c8a..822de1e02797 100644 --- a/apps/server/src/sourceControl/gitHubPullRequests.ts +++ b/apps/server/src/sourceControl/gitHubPullRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitHubPullRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -29,6 +31,7 @@ const GitHubPullRequestSchema = Schema.Struct({ headRefName: TrimmedNonEmptyString, state: Schema.optional(Schema.NullOr(Schema.String)), isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), isCrossRepository: Schema.optional(Schema.Boolean), @@ -96,6 +99,8 @@ function normalizeGitHubPullRequestRecord( headRefName: raw.headRefName, state: normalizeGitHubPullRequestState(raw), ...(raw.isDraft === true ? { isDraft: true } : {}), + closedAt: raw.closedAt ?? null, + mergedAt: raw.mergedAt ?? null, updatedAt: raw.updatedAt ?? Option.none(), ...(typeof raw.isCrossRepository === "boolean" ? { isCrossRepository: raw.isCrossRepository } diff --git a/apps/server/src/sourceControl/gitLabMergeRequests.ts b/apps/server/src/sourceControl/gitLabMergeRequests.ts index 3b032e245bbc..0525260df51b 100644 --- a/apps/server/src/sourceControl/gitLabMergeRequests.ts +++ b/apps/server/src/sourceControl/gitLabMergeRequests.ts @@ -15,6 +15,8 @@ export interface NormalizedGitLabMergeRequestRecord { readonly headRefName: string; readonly state: "open" | "closed" | "merged"; readonly isDraft?: boolean; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; readonly updatedAt: Option.Option; readonly isCrossRepository?: boolean; readonly headRepositoryNameWithOwner?: string | null; @@ -44,6 +46,8 @@ const GitLabMergeRequestSchema = Schema.Struct({ state: Schema.optional(Schema.NullOr(Schema.String)), draft: Schema.optional(Schema.Boolean), work_in_progress: Schema.optional(Schema.Boolean), + closed_at: Schema.optional(Schema.NullOr(Schema.String)), + merged_at: Schema.optional(Schema.NullOr(Schema.String)), updated_at: Schema.optional(Schema.OptionFromNullOr(Schema.DateTimeUtcFromString)), source_project_id: Schema.optional(Schema.NullOr(Schema.Number)), target_project_id: Schema.optional(Schema.NullOr(Schema.Number)), @@ -112,6 +116,8 @@ function normalizeGitLabMergeRequestRecord( headRefName: raw.source_branch, state: normalizeGitLabMergeRequestState(raw.state), ...(raw.draft === true || raw.work_in_progress === true ? { isDraft: true } : {}), + closedAt: raw.closed_at ?? null, + mergedAt: raw.merged_at ?? null, updatedAt: raw.updated_at ?? Option.none(), ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}), ...(sourceProjectPath ? { headRepositoryNameWithOwner: sourceProjectPath } : {}), diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index e4aa17b383c8..227d13d18bb7 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -627,6 +627,14 @@ export function PullRequestDetailPanel({ : { ...resolvedCoreDetail, ...sharedSummary, + closedAt: + sharedSummary.closedAt === undefined + ? resolvedCoreDetail.closedAt + : sharedSummary.closedAt, + mergedAt: + sharedSummary.mergedAt === undefined + ? resolvedCoreDetail.mergedAt + : sharedSummary.mergedAt, // A summary may come from an older server that does not report draft state. Keep the // detail's required value instead of making the complete detail shape partial. isDraft: sharedSummary.isDraft ?? resolvedCoreDetail.isDraft, diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 4b63b877923f..345adcc7849c 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -201,11 +201,9 @@ const VcsStatusChangeRequest = Schema.Struct({ /** Optional for compatibility with older servers and providers. */ isDraft: Schema.optional(Schema.Boolean), /** - * Last provider-side activity (ISO). For a merged/closed change request - * this bounds when it reached that state, so clients can tell a PR that - * terminated during a thread's life from one that was already history - * when the thread was created. Optional for old servers and providers - * whose lookups do not report it. + * Last provider-side activity (ISO), including comments and metadata edits. + * This is not the time a change request closed or merged. Optional for old + * servers and providers whose lookups do not report it. */ updatedAt: Schema.optional(Schema.NullOr(Schema.String)), }); diff --git a/packages/contracts/src/pullRequest.ts b/packages/contracts/src/pullRequest.ts index f766578bb1a3..812489a5fb9f 100644 --- a/packages/contracts/src/pullRequest.ts +++ b/packages/contracts/src/pullRequest.ts @@ -638,6 +638,8 @@ export const PullRequestSummary = Schema.Struct({ isDraft: Schema.optional(Schema.Boolean), headBranch: TrimmedNonEmptyString, baseBranch: TrimmedNonEmptyString, + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: IsoDateTime, }); export type PullRequestSummary = typeof PullRequestSummary.Type; diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts index be3d70aefadd..b013eea3bb6f 100644 --- a/packages/contracts/src/sourceControl.ts +++ b/packages/contracts/src/sourceControl.ts @@ -31,6 +31,8 @@ export const ChangeRequest = Schema.Struct({ state: ChangeRequestState, /** Present when the provider can tell that an open change request is still a draft. */ isDraft: Schema.optional(Schema.Boolean), + closedAt: Schema.optional(Schema.NullOr(Schema.String)), + mergedAt: Schema.optional(Schema.NullOr(Schema.String)), updatedAt: Schema.Option(Schema.DateTimeUtc), isCrossRepository: Schema.optional(Schema.Boolean), headRepositoryNameWithOwner: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), From eee05575ebd514db36f61d7eb05d2258a10c96bd Mon Sep 17 00:00:00 2001 From: Wout Stiens <71498452+StiensWout@users.noreply.github.com> Date: Sun, 6 Sep 2026 03:50:02 +0200 Subject: [PATCH 165/320] fix(clients): persist project icons across reloads and reconnects (#10138) --- apps/mobile/src/components/ProjectFavicon.tsx | 44 ++- .../environment-cache-store.test.ts | 6 + .../src/connection/environment-cache-store.ts | 11 +- .../src/lib/projectFaviconCache.test.ts | 79 +++++ apps/mobile/src/lib/projectFaviconCache.ts | 112 ++++++ .../mobile/src/persistence/mobile-database.ts | 23 +- apps/mobile/src/state/assets.ts | 10 +- apps/mobile/src/state/client-cache-state.ts | 8 +- apps/web/src/assets/projectFaviconCache.ts | 85 +++++ .../src/components/ProjectFavicon.test.tsx | 18 +- apps/web/src/components/ProjectFavicon.tsx | 34 +- .../components/preview/PreviewView.test.tsx | 3 +- apps/web/src/connection/storage.ts | 3 + apps/web/src/state/assets.ts | 13 +- packages/client-runtime/package.json | 4 + .../src/projectFaviconCache.test.ts | 323 ++++++++++++++++++ .../client-runtime/src/projectFaviconCache.ts | 263 ++++++++++++++ .../client-runtime/src/state/assets.test.ts | 165 ++++++++- packages/client-runtime/src/state/assets.ts | 57 ++++ packages/shared/src/projectFavicon.ts | 8 + 20 files changed, 1207 insertions(+), 62 deletions(-) create mode 100644 apps/mobile/src/lib/projectFaviconCache.test.ts create mode 100644 apps/mobile/src/lib/projectFaviconCache.ts create mode 100644 apps/web/src/assets/projectFaviconCache.ts create mode 100644 packages/client-runtime/src/projectFaviconCache.test.ts create mode 100644 packages/client-runtime/src/projectFaviconCache.ts diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index c60709baf4c9..932fc6779f20 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -5,9 +5,13 @@ import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; import { getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; -import { useAssetUrl } from "../state/assets"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/unstable/reactivity"; +import { projectFaviconUrlAtom } from "../state/assets"; + import { beginProjectFaviconRequest, createProjectFaviconRequest, @@ -16,6 +20,8 @@ import { markProjectFaviconLoaded, } from "./projectFaviconCache"; +const EMPTY_FAVICON_URL = Atom.make(null); + /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { readonly environmentId: EnvironmentId; @@ -26,20 +32,23 @@ export function ProjectFavicon(props: { readonly faviconPath?: string | null; }) { const size = props.size ?? 42; - const faviconUrl = useAssetUrl( - props.environmentId, - props.workspaceRoot === null || props.workspaceRoot === undefined - ? null - : { - _tag: "project-favicon", + const faviconUrl = useAtomValue( + props.workspaceRoot == null + ? EMPTY_FAVICON_URL + : projectFaviconUrlAtom({ + environmentId: props.environmentId, cwd: props.workspaceRoot, - ...(props.faviconPath ? { path: props.faviconPath } : {}), - }, + faviconPath: props.faviconPath, + }), ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; + // Inline images are self-contained; remote URLs key on their revision so signed-token + // rotation reuses the disk cache while a changed icon starts from the loading state. const cacheKey = renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) + ? renderableFaviconUrl.startsWith("data:") + ? getProjectFaviconResourceKey(props.environmentId, props.workspaceRoot, props.faviconPath) + : getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) : null; return ( @@ -75,7 +84,9 @@ function ProjectFaviconImage(props: { }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl?.startsWith("data:") || hasLoadedProjectFavicon(props.cacheKey) + ? "loaded" + : "loading", ); const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; @@ -104,11 +115,12 @@ function ProjectFaviconImage(props: { {requestIsActive ? ( Effect.succeed(Option.fromUndefinedOr(values.get(cacheId(environmentId, kind, cacheKey)))), + listCache: (kind) => + Effect.sync(() => + [...values.entries()] + .filter(([key]) => key.split(":")[1] === kind) + .map(([, payload]) => payload), + ), saveCache: (environmentId, kind, cacheKey, _schemaVersion, payload) => Effect.sync(() => { values.set(cacheId(environmentId, kind, cacheKey), payload); diff --git a/apps/mobile/src/connection/environment-cache-store.ts b/apps/mobile/src/connection/environment-cache-store.ts index ad5ef13b62d5..ccf4945b3bef 100644 --- a/apps/mobile/src/connection/environment-cache-store.ts +++ b/apps/mobile/src/connection/environment-cache-store.ts @@ -15,6 +15,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as MobileDatabase from "../persistence/mobile-database"; +import { attachProjectFaviconDatabase, projectFaviconCache } from "../lib/projectFaviconCache"; const SHELL_SNAPSHOT_CACHE_SCHEMA_VERSION = 1; // v3 adds windowed (paginated) snapshots carrying `page` metadata; the bump @@ -115,6 +116,7 @@ function loadDecodedCache(input: { export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { const database = yield* MobileDatabase.MobileDatabase; + attachProjectFaviconDatabase(database); return EnvironmentCacheStore.of({ loadShell: Effect.fn("MobileEnvironmentCache.loadShell")((environmentId) => loadDecodedCache({ @@ -126,7 +128,7 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { decode: decodeStoredShellSnapshot, select: (stored) => stored.environmentId === environmentId ? Option.some(stored.snapshot) : Option.none(), - }), + }).pipe(Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate()))), ), saveShell: Effect.fn("MobileEnvironmentCache.saveShell")(function* (environmentId, snapshot) { const payload = yield* encodeStoredShellSnapshot({ @@ -237,9 +239,10 @@ export const make = Effect.fn("MobileEnvironmentCacheStore.make")(function* () { .pipe(Effect.mapError(mapDatabaseError("clear-vcs-refs"))), ), clear: Effect.fn("MobileEnvironmentCache.clear")((environmentId) => - database - .clearEnvironmentCache(environmentId) - .pipe(Effect.mapError(mapDatabaseError("clear-environment"))), + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)).pipe( + Effect.andThen(database.clearEnvironmentCache(environmentId)), + Effect.mapError(mapDatabaseError("clear-environment")), + ), ), }); }); diff --git a/apps/mobile/src/lib/projectFaviconCache.test.ts b/apps/mobile/src/lib/projectFaviconCache.test.ts new file mode 100644 index 000000000000..adde56fbf0b1 --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.test.ts @@ -0,0 +1,79 @@ +import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; +import { PROJECT_FAVICON_MAX_DATA_URL_LENGTH } from "@t3tools/client-runtime/project-favicon-cache"; + +const native = vi.hoisted(() => ({ + load: vi.fn(async (_url: string, options: { maxWidth: number; maxHeight: number }) => ({ + width: options.maxWidth, + height: options.maxHeight, + release: vi.fn(), + })), + write: vi.fn(async () => {}), + path: vi.fn(async () => "/cache/thumbnail"), + read: vi.fn(), + remove: vi.fn(), +})); +vi.mock("expo-image", () => ({ + Image: { + loadAsync: native.load, + writeToCacheAsync: native.write, + getCachePathAsync: native.path, + }, +})); +vi.mock("expo-file-system", () => ({ + File: class { + size = 24_000; + base64 = native.read; + delete = native.remove; + }, +})); + +import { downscaleProjectFavicon } from "./projectFaviconCache"; + +const png = "iVBORw0KGgoAAAAA"; +const image = { url: "https://remote/icon.png" }; + +beforeEach(() => { + vi.clearAllMocks(); + native.read.mockReset().mockResolvedValue(png); + native.load.mockReset().mockImplementation(async (_url, { maxWidth }) => ({ + width: maxWidth, + height: maxWidth, + release: vi.fn(), + })); +}); + +describe("mobile project icon thumbnails", () => { + it("reduces an oversized encoding and deletes temporary thumbnail files", async () => { + native.read.mockResolvedValueOnce( + `iVBORw0KGgo${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + const thumbnail = await downscaleProjectFavicon(image, new AbortController().signal); + expect(thumbnail).toBe(`data:image/png;base64,${png}`); + expect(native.load.mock.calls.map(([, options]) => options.maxWidth)).toEqual([96, 48]); + expect(native.remove).toHaveBeenCalledTimes(2); + for (const call of native.load.mock.results) + expect((await call.value).release).toHaveBeenCalledOnce(); + }); + + it("releases a decoded image when its request was canceled", async () => { + const controller = new AbortController(); + const release = vi.fn(); + native.load.mockImplementationOnce(async () => { + controller.abort(); + return { width: 96, height: 96, release }; + }); + await expect(downscaleProjectFavicon(image, controller.signal)).rejects.toThrow(); + expect(release).toHaveBeenCalledOnce(); + expect(native.write).not.toHaveBeenCalled(); + }); + + it("rejects an image the native decoder did not downsize", async () => { + const release = vi.fn(); + native.load.mockResolvedValueOnce({ width: 4000, height: 3000, release }); + await expect(downscaleProjectFavicon(image, new AbortController().signal)).rejects.toThrow( + "not resized", + ); + expect(native.write).not.toHaveBeenCalled(); + expect(release).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/mobile/src/lib/projectFaviconCache.ts b/apps/mobile/src/lib/projectFaviconCache.ts new file mode 100644 index 000000000000..26a6d848d11d --- /dev/null +++ b/apps/mobile/src/lib/projectFaviconCache.ts @@ -0,0 +1,112 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, + type ProjectFaviconEntry, +} from "@t3tools/client-runtime/project-favicon-cache"; +import * as Effect from "effect/Effect"; + +import * as MobileDatabase from "../persistence/mobile-database"; + +const CACHE_KIND = "project-favicon"; +const CACHE_SCHEMA_VERSION = 1; + +let database: MobileDatabase.MobileDatabase["Service"] | undefined; + +/** + * The cache is a module singleton because the favicon atom family holds it outside + * any Effect runtime. Its rows live in `client_cache`, so the environment cache store + * hands over the database it already owns instead of the cache re-entering the runtime. + */ +export function attachProjectFaviconDatabase(service: MobileDatabase.MobileDatabase["Service"]) { + database = service; +} + +const runDatabase = ( + use: (database: MobileDatabase.MobileDatabase["Service"]) => Effect.Effect, +) => + database + ? Effect.runPromise(use(database)) + : Promise.reject(new Error("Project icon storage is not attached.")); + +/** + * Rasterizes a bitmap that is too large to inline. The native decoder writes the + * downsized frame to expo-image's disk cache, which is the only encode path it + * exposes; the temporary entry is removed once its bytes are read. + */ +export async function downscaleProjectFavicon( + image: { readonly url: string }, + signal: AbortSignal, +) { + const [{ Image }, { File }] = await Promise.all([ + import("expo-image"), + import("expo-file-system"), + ]); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + signal.throwIfAborted(); + const decoded = await Image.loadAsync(image.url, { maxWidth: size, maxHeight: size }); + const cacheKey = `t3-favicon-thumbnail:${size}:${image.url}`; + try { + signal.throwIfAborted(); + if (decoded.width > size || decoded.height > size) { + throw new Error("Project icon was not resized."); + } + await Image.writeToCacheAsync(decoded, cacheKey); + const path = await Image.getCachePathAsync(cacheKey); + if (!path) throw new Error("Project icon thumbnail was not written."); + const file = new File(path.startsWith("file:") ? path : `file://${path}`); + try { + if (file.size > PROJECT_FAVICON_MAX_DATA_URL_LENGTH) continue; + const base64 = await file.base64(); + // SDWebImage chooses JPEG for opaque images and PNG for transparency; Glide always writes PNG. + const mimeType = base64.startsWith("/9j/") + ? "image/jpeg" + : base64.startsWith("iVBORw0KGgo") + ? "image/png" + : null; + if (!mimeType) throw new Error("Unsupported project icon thumbnail encoding."); + const dataUrl = `data:${mimeType};base64,${base64}`; + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } finally { + file.delete(); + } + } finally { + decoded.release(); + } + } + throw new Error("Project icon thumbnail exceeds the cache limit."); +} + +/** Rows live in `client_cache` so Settings → Client storage counts and clears them. */ +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: () => + runDatabase((database) => + database.listCache(CACHE_KIND).pipe( + Effect.map((payloads) => + payloads.flatMap((payload): Array => { + try { + return [JSON.parse(payload)]; + } catch { + return []; + } + }), + ), + ), + ), + put: (key, entry: ProjectFaviconEntry) => + runDatabase((database) => + database.saveCache( + entry.environmentId, + CACHE_KIND, + key, + CACHE_SCHEMA_VERSION, + JSON.stringify(entry), + ), + ), + remove: (key, entry) => + runDatabase((database) => database.removeCache(entry.environmentId, CACHE_KIND, key)), + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/mobile/src/persistence/mobile-database.ts b/apps/mobile/src/persistence/mobile-database.ts index 71876932b789..aca830f24c71 100644 --- a/apps/mobile/src/persistence/mobile-database.ts +++ b/apps/mobile/src/persistence/mobile-database.ts @@ -16,7 +16,13 @@ const LEGACY_CACHE_DIRECTORIES = [ "connection-vcs-refs", ] as const; -export const ClientCacheKind = Schema.Literals(["shell", "thread", "server-config", "vcs-refs"]); +export const ClientCacheKind = Schema.Literals([ + "shell", + "thread", + "server-config", + "vcs-refs", + "project-favicon", +]); export type ClientCacheKind = typeof ClientCacheKind.Type; export interface ClientCacheSummaryRow { @@ -44,6 +50,7 @@ const MobileDatabaseOperation = Schema.Literals([ "open", "migrate", "load-cache", + "list-cache", "save-cache", "remove-cache", "clear-cache-kind", @@ -192,6 +199,9 @@ export class MobileDatabase extends Context.Service< kind: ClientCacheKind, cacheKey: string, ) => Effect.Effect, MobileDatabaseError>; + readonly listCache: ( + kind: ClientCacheKind, + ) => Effect.Effect, MobileDatabaseError>; readonly saveCache: ( environmentId: EnvironmentId, kind: ClientCacheKind, @@ -292,6 +302,16 @@ const makeAvailable = Effect.gen(function* () { catch: databaseError("load-cache"), }).pipe(Effect.map((row) => Option.fromNullishOr(row?.payload))), ), + listCache: Effect.fn("MobileDatabase.listCache")((kind) => + Effect.tryPromise({ + try: () => + database.getAllAsync<{ readonly payload: string }>( + "SELECT payload FROM client_cache WHERE kind = ? ORDER BY updated_at", + kind, + ), + catch: databaseError("list-cache"), + }).pipe(Effect.map((rows) => rows.map((row) => row.payload))), + ), saveCache: Effect.fn("MobileDatabase.saveCache")( (environmentId, kind, cacheKey, schemaVersion, payload) => Effect.tryPromise({ @@ -405,6 +425,7 @@ function makeUnavailable(error: MobileDatabaseError): MobileDatabase["Service"] const fail = Effect.fail(error); return MobileDatabase.of({ loadCache: () => fail, + listCache: () => fail, saveCache: () => fail, removeCache: () => fail, clearCacheKind: () => fail, diff --git a/apps/mobile/src/state/assets.ts b/apps/mobile/src/state/assets.ts index 400bdb6b705a..15cbd1d9a89f 100644 --- a/apps/mobile/src/state/assets.ts +++ b/apps/mobile/src/state/assets.ts @@ -6,6 +6,7 @@ import { import { assetUrlStateFromResult, createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, EMPTY_ASSET_URL_ATOM, } from "@t3tools/client-runtime/state/assets"; import type { AssetResource, EnvironmentId } from "@t3tools/contracts"; @@ -15,14 +16,21 @@ import { useCallback } from "react"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import { type AssetUrlState, deriveAssetUrlState } from "./asset-url-state"; -import { usePreparedConnection } from "./session"; +import { environmentSession, usePreparedConnection } from "./session"; import { useAtomQueryRunner } from "./use-atom-query-runner"; export type { AssetUrlFailureReason, AssetUrlState } from "./asset-url-state"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); + const EMPTY_CONNECTION_STATE_ATOM = Atom.make(AsyncResult.initial(false)).pipe( Atom.withLabel("mobile-asset-connection-state:empty"), ); diff --git a/apps/mobile/src/state/client-cache-state.ts b/apps/mobile/src/state/client-cache-state.ts index 3912857b5751..c210c54f2fdd 100644 --- a/apps/mobile/src/state/client-cache-state.ts +++ b/apps/mobile/src/state/client-cache-state.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import { Atom } from "effect/unstable/reactivity"; import { type ClientCacheKind, MobileDatabase } from "../persistence/mobile-database"; +import { projectFaviconCache } from "../lib/projectFaviconCache"; import * as Runtime from "../lib/runtime"; export interface EnvironmentClientCacheSummary { @@ -71,7 +72,12 @@ export const clientCacheSummaryAtom = clientCacheRuntime export const clearClientCacheAtom = clientCacheRuntime .fn((scope: ClientCacheClearScope, get) => - MobileDatabase.pipe( + Effect.promise(() => + scope.type === "all" + ? projectFaviconCache.clearAll() + : projectFaviconCache.clearEnvironment(scope.environmentId), + ).pipe( + Effect.andThen(MobileDatabase), Effect.flatMap((database) => scope.type === "all" ? database.clearAllCaches diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts new file mode 100644 index 000000000000..e6fcc817b969 --- /dev/null +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -0,0 +1,85 @@ +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_THUMBNAIL_SIZE, +} from "@t3tools/client-runtime/project-favicon-cache"; + +const DATABASE_NAME = "t3code:project-favicons"; +const DATABASE_VERSION = 2; +const STORE_NAME = "images"; +let database: Promise | undefined; + +function openDatabase() { + return (database ??= new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + request.addEventListener("upgradeneeded", () => { + for (const name of request.result.objectStoreNames) { + if (name !== STORE_NAME) request.result.deleteObjectStore(name); + } + if (!request.result.objectStoreNames.contains(STORE_NAME)) { + request.result.createObjectStore(STORE_NAME); + } + }); + request.addEventListener("success", () => resolve(request.result)); + request.addEventListener("error", () => reject(request.error)); + request.addEventListener("blocked", () => reject(new Error("Project icon cache is blocked."))); + })); +} + +function completed(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.addEventListener("complete", () => resolve()); + transaction.addEventListener("abort", () => reject(transaction.error)); + transaction.addEventListener("error", () => reject(transaction.error)); + }); +} + +async function withStore( + mode: IDBTransactionMode, + use: (store: IDBObjectStore) => IDBRequest | void, +) { + const transaction = (await openDatabase()).transaction(STORE_NAME, mode); + const request = use(transaction.objectStore(STORE_NAME)); + await completed(transaction); + return request?.result; +} + +/** Rasterizes a bitmap that is too large to inline, retrying at half size. */ +export async function downscaleProjectFavicon( + image: { readonly mimeType: string; readonly bytes: Uint8Array }, + signal: AbortSignal, +) { + const bitmap = await createImageBitmap(new Blob([image.bytes], { type: image.mimeType })); + try { + signal.throwIfAborted(); + const canvas = document.createElement("canvas"); + for (const size of [PROJECT_FAVICON_THUMBNAIL_SIZE, PROJECT_FAVICON_THUMBNAIL_SIZE / 2]) { + const scale = Math.min(1, size / bitmap.width, size / bitmap.height); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); + canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const context = canvas.getContext("2d"); + if (!context) throw new Error("Canvas is unavailable."); + context.clearRect(0, 0, canvas.width, canvas.height); + context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + const dataUrl = canvas.toDataURL("image/webp", 0.85); + if (dataUrl.length <= PROJECT_FAVICON_MAX_DATA_URL_LENGTH) return dataUrl; + } + throw new Error("Project icon thumbnail exceeds the cache limit."); + } finally { + bitmap.close(); + } +} + +export const projectFaviconCache = createProjectFaviconCache({ + storage: { + list: async () => (await withStore("readonly", (store) => store.getAll())) ?? [], + put: async (key, entry) => { + await withStore("readwrite", (store) => store.put(entry, key)); + }, + remove: async (key) => { + await withStore("readwrite", (store) => store.delete(key)); + }, + }, + load: createProjectFaviconImageLoader({ downscale: downscaleProjectFavicon }), +}); diff --git a/apps/web/src/components/ProjectFavicon.test.tsx b/apps/web/src/components/ProjectFavicon.test.tsx index bfb5487031b7..98458cfa2dcd 100644 --- a/apps/web/src/components/ProjectFavicon.test.tsx +++ b/apps/web/src/components/ProjectFavicon.test.tsx @@ -5,7 +5,7 @@ import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon" const testState = vi.hoisted(() => ({ faviconUrl: "https://environment.test/api/assets/token-a/v1-20-favicon.svg", - lastResource: null as unknown, + lastTarget: null as unknown, })); const hooks = vi.hoisted(() => { @@ -57,10 +57,12 @@ vi.mock("lucide-react/dynamic", () => ({ DynamicIcon: "dynamic-icon", iconNames: ["alarm-clock", "folder-code"], })); -vi.mock("../assets/assetUrls", () => ({ - useAssetUrlState: (_environmentId: unknown, resource: unknown) => { - testState.lastResource = resource; - return { _tag: "Success", url: testState.faviconUrl }; +vi.mock("@effect/atom-react", () => ({ + useAtomValue: () => testState.faviconUrl, +})); +vi.mock("../state/assets", () => ({ + projectFaviconUrlAtom: (input: unknown) => { + testState.lastTarget = input; }, })); @@ -212,10 +214,10 @@ describe("ProjectFavicon", () => { faviconPath: "brand/icon.svg", }); - expect(testState.lastResource).toEqual({ - _tag: "project-favicon", + expect(testState.lastTarget).toMatchObject({ + environmentId: "environment-test", cwd: "/workspace-test", - path: "brand/icon.svg", + faviconPath: "brand/icon.svg", }); }); }); diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index 2ebc6e267443..19467adde404 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,6 +1,6 @@ import type { EnvironmentId, ProjectIconColor, ProjectIconOverride } from "@t3tools/contracts"; import { - getProjectFaviconCacheKey, + getProjectFaviconResourceKey, isProjectFaviconFallbackUrl, } from "@t3tools/shared/projectFavicon"; import { @@ -30,12 +30,12 @@ import { import type { IconName } from "lucide-react/dynamic"; import type { ComponentType } from "react"; import { lazy, Suspense, useState } from "react"; -import { useAssetUrlState } from "../assets/assetUrls"; +import { useAtomValue } from "@effect/atom-react"; +import { projectFaviconUrlAtom } from "../state/assets"; import { selectProjectIcon, type ProjectIconName } from "../projectIconModel"; import { projectIconColorClassName } from "../projectIconColors"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Map(); const DynamicIcon = lazy(() => import("lucide-react/dynamic").then((module) => ({ default: module.DynamicIcon })), ); @@ -103,8 +103,7 @@ export function ProjectFavicon(input: { className?: string | undefined; fallbackIcon?: ComponentType<{ className?: string }>; }) { - const state = useProjectFaviconAsset(input); - const src = state._tag === "Success" ? state.url : null; + const src = useAtomValue(projectFaviconUrlAtom(input)); if (input.projectIcon?.kind === "emoji") { return ; } @@ -150,12 +149,11 @@ export function ProjectFavicon(input: { ); } - const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + const cacheKey = getProjectFaviconResourceKey(input.environmentId, input.cwd, input.faviconPath); return ( | undefined; readonly fallbackEmoji?: string | undefined; readonly fallbackColorClassName?: string | undefined; }) { - const [displayedSrc, setDisplayedSrc] = useState( - () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, + const [displayedSrc, setDisplayedSrc] = useState(() => + src.startsWith("data:image/") ? src : null, ); const isLoading = displayedSrc !== src; const handleLoadError = (failedSrc: string) => { - if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { - loadedProjectFaviconSrcs.delete(cacheKey); - } setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); }; @@ -256,7 +237,6 @@ function ProjectFaviconImage({ alt="" className="hidden" onLoad={() => { - loadedProjectFaviconSrcs.set(cacheKey, src); setDisplayedSrc(src); }} onError={() => handleLoadError(src)} diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index 9456daef72d8..2a146834012e 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -56,7 +56,8 @@ vi.mock("~/browserHistoryStore", () => ({ useThreadRecentHistory: () => EMPTY_HISTORY, })); -vi.mock("~/state/session", () => ({ +vi.mock("~/state/session", async (importOriginal) => ({ + ...(await importOriginal()), readPreparedConnection: mocks.readPreparedConnection, })); diff --git a/apps/web/src/connection/storage.ts b/apps/web/src/connection/storage.ts index 0a1183d48abd..8ec2b16add76 100644 --- a/apps/web/src/connection/storage.ts +++ b/apps/web/src/connection/storage.ts @@ -33,6 +33,7 @@ import * as Option from "effect/Option"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; const DATABASE_NAME = "t3code:connection-runtime"; const DATABASE_VERSION = 4; @@ -461,6 +462,7 @@ export const connectionStorageLayer = Layer.effectContext( const cacheStore = EnvironmentCacheStore.of({ loadShell: (environmentId) => readDatabaseValue(database, SHELL_STORE_NAME, environmentId).pipe( + Effect.tap(() => Effect.promise(() => projectFaviconCache.hydrate())), Effect.flatMap((raw) => { if (typeof raw !== "string") { return Effect.succeed(Option.none()); @@ -638,6 +640,7 @@ export const connectionStorageLayer = Layer.effectContext( clear: (environmentId) => Effect.all( [ + Effect.promise(() => projectFaviconCache.clearEnvironment(environmentId)), removeDatabaseValue(database, SHELL_STORE_NAME, environmentId), removeDatabaseValuesInRange( database, diff --git a/apps/web/src/state/assets.ts b/apps/web/src/state/assets.ts index 5e31beb826b5..d1ef71f8662d 100644 --- a/apps/web/src/state/assets.ts +++ b/apps/web/src/state/assets.ts @@ -1,5 +1,16 @@ -import { createAssetEnvironmentAtoms } from "@t3tools/client-runtime/state/assets"; +import { + createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, +} from "@t3tools/client-runtime/state/assets"; import { connectionAtomRuntime } from "../connection/runtime"; +import { projectFaviconCache } from "../assets/projectFaviconCache"; +import { environmentSession } from "./session"; export const assetEnvironment = createAssetEnvironmentAtoms(connectionAtomRuntime); + +export const projectFaviconUrlAtom = createProjectFaviconUrlAtomFamily({ + imageCache: projectFaviconCache, + createUrl: assetEnvironment.createUrl, + preparedConnection: environmentSession.preparedConnectionValueAtom, +}); diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index c7d921393444..7409a194a2fd 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./project-favicon-cache": { + "types": "./src/projectFaviconCache.ts", + "default": "./src/projectFaviconCache.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/projectFaviconCache.test.ts b/packages/client-runtime/src/projectFaviconCache.test.ts new file mode 100644 index 000000000000..980ccf495165 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.test.ts @@ -0,0 +1,323 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { EnvironmentId } from "@t3tools/contracts"; + +import { + createProjectFaviconCache, + createProjectFaviconImageLoader, + PROJECT_FAVICON_CACHE_MAX_BYTES, + PROJECT_FAVICON_CACHE_MAX_ENTRIES, + PROJECT_FAVICON_MAX_DATA_URL_LENGTH, + PROJECT_FAVICON_MAX_SOURCE_BYTES, + type ProjectFaviconEntry, + type ProjectFaviconStorage, +} from "./projectFaviconCache.ts"; + +const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; +const url = "https://remote.test/api/assets/token-a/vabc-icon.svg"; +const image = "data:image/png;base64,aWNvbg=="; +const replacement = "data:image/png;base64,bmV3"; +const signal = () => new AbortController().signal; + +function deferred() { + let resolve!: (value: A) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} + +function fixture() { + const records = new Map(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + records.delete(key); + }, + }; + return { + storage, + load, + records, + cache: createProjectFaviconCache({ storage, load }), + }; +} + +describe("persistent project favicon cache", () => { + it("restores image bytes in a fresh client before any remote response", async () => { + const { cache, storage, load } = fixture(); + expect(await cache.resolve(target, url, signal())).toBe(image); + await cache.flush(); + const reloaded = createProjectFaviconCache({ storage, load }); + await reloaded.hydrate(); + expect(reloaded.peek(target)).toBe(image); + expect(await reloaded.resolve(target, null, signal())).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("reuses the image when signed URLs or connection origins change", async () => { + const { cache, load } = fixture(); + await cache.resolve(target, url, signal()); + expect( + await cache.resolve(target, "https://new.test/api/assets/token-b/vabc-icon.svg", signal()), + ).toBe(image); + expect(load).toHaveBeenCalledTimes(1); + }); + + it("keeps the old image during refresh and failures, then persists its replacement", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + load.mockImplementationOnce(() => next.promise); + const refreshing = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + expect(cache.peek(target)).toBe(image); + next.resolve(replacement); + expect(await refreshing).toBe(replacement); + load.mockRejectedValueOnce(new Error("offline")); + expect(await cache.resolve(target, url, signal())).toBe(replacement); + await cache.flush(); + expect(await createProjectFaviconCache({ storage, load }).resolve(target, null, signal())).toBe( + replacement, + ); + }); + + it("persists confirmed removal and ignores an aborted older download", async () => { + const { cache, load, storage } = fixture(); + await cache.resolve(target, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const controller = new AbortController(); + const pending = cache.resolve(target, url.replace("vabc", "vdef"), controller.signal); + await started.promise; + controller.abort(); + expect( + await cache.resolve( + target, + "https://remote.test/api/assets/token/project-favicon-missing", + signal(), + ), + ).toBeNull(); + next.resolve(replacement); + await pending; + await cache.flush(); + expect( + await createProjectFaviconCache({ storage, load }).resolve(target, null, signal()), + ).toBeNull(); + }); + + it("isolates environments, workspaces, and icon selections", async () => { + const { cache } = fixture(); + await cache.resolve(target, url, signal()); + expect(cache.peek({ ...target, faviconPath: null })).toBe(image); + expect(cache.peek({ ...target, faviconPath: "brand.svg" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/other" })).toBeNull(); + expect(cache.peek({ ...target, environmentId: EnvironmentId.make("other") })).toBeNull(); + }); + + it.each([ + { + scope: "one environment", + clear: (cache: ReturnType["cache"]) => + cache.clearEnvironment(target.environmentId), + remaining: 1, + }, + { + scope: "every environment", + clear: (cache: ReturnType["cache"]) => cache.clearAll(), + remaining: 0, + }, + ])( + "does not restore images for $scope removed during a download", + async ({ clear, remaining }) => { + const { cache, load, records } = fixture(); + const other = { ...target, environmentId: EnvironmentId.make("other") }; + await cache.resolve(other, url, signal()); + const next = deferred(); + const started = deferred(); + load.mockImplementationOnce(() => { + started.resolve(); + return next.promise; + }); + const pending = cache.resolve(target, url, signal()); + await started.promise; + await clear(cache); + next.resolve(image); + await pending; + await cache.flush(); + expect(cache.peek(target)).toBeNull(); + expect(records.size).toBe(remaining); + }, + ); + + it("discards a download that starts while the environment is being cleared", async () => { + const records = new Map(); + const removal = deferred(); + const load = vi.fn(async () => image); + const storage: ProjectFaviconStorage = { + list: async () => [...records.values()], + put: async (key, entry) => { + records.set(key, entry); + }, + remove: async (key) => { + await removal.promise; + records.delete(key); + }, + }; + const cache = createProjectFaviconCache({ storage, load }); + await cache.resolve(target, url, signal()); + await cache.flush(); + const clearing = cache.clearEnvironment(target.environmentId); + await Promise.resolve(); + const late = cache.resolve(target, url.replace("vabc", "vdef"), signal()); + removal.resolve(); + await clearing; + expect(records.size).toBe(0); + expect(cache.peek(target)).toBeNull(); + expect(load).toHaveBeenCalledTimes(1); + expect(await late).toBe(image); + expect(load).toHaveBeenCalledTimes(2); + }); + + it("bounds individual images, total bytes, and entry count in storage", async () => { + const { cache, load, records } = fixture(); + load.mockResolvedValueOnce( + `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH)}`, + ); + expect(await cache.resolve(target, url, signal())).toBe(url); + expect(cache.peek(target)).toBeNull(); + const large = `data:image/png;base64,${"a".repeat(PROJECT_FAVICON_MAX_DATA_URL_LENGTH - 32)}`; + load.mockResolvedValue(large); + for (let i = 0; i < 40; i++) { + await cache.resolve({ ...target, cwd: `/large-${i}` }, url, signal()); + } + await cache.flush(); + expect( + [...records.values()].reduce((total, entry) => total + entry.dataUrl.length, 0), + ).toBeLessThanOrEqual(PROJECT_FAVICON_CACHE_MAX_BYTES); + expect(cache.peek({ ...target, cwd: "/large-0" })).toBeNull(); + expect(cache.peek({ ...target, cwd: "/large-39" })).toBe(large); + load.mockResolvedValue(image); + for (let i = 0; i <= PROJECT_FAVICON_CACHE_MAX_ENTRIES; i++) { + await cache.resolve({ ...target, cwd: `/small-${i}` }, url, signal()); + } + await cache.flush(); + expect(records.size).toBe(PROJECT_FAVICON_CACHE_MAX_ENTRIES); + expect(cache.peek({ ...target, cwd: "/small-0" })).toBeNull(); + }); + + it("skips corrupt records and tolerates unavailable storage", async () => { + const corrupt = createProjectFaviconCache({ + storage: { + list: async () => [ + { ...target, faviconPath: null, revision: "r", dataUrl: image }, + { ...target, cwd: "/broken", faviconPath: null, revision: "r", dataUrl: "not-an-image" }, + "garbage", + ], + put: async () => {}, + remove: async () => {}, + }, + load: async () => replacement, + }); + await corrupt.hydrate(); + expect(corrupt.peek(target)).toBe(image); + expect(corrupt.peek({ ...target, cwd: "/broken" })).toBeNull(); + + const unavailable = createProjectFaviconCache({ + storage: { + list: async () => { + throw new Error("storage unavailable"); + }, + put: async () => { + throw new Error("quota exceeded"); + }, + remove: async () => { + throw new Error("quota exceeded"); + }, + }, + load: async () => image, + }); + expect(await unavailable.resolve(target, url, signal())).toBe(image); + await unavailable.flush(); + expect(unavailable.peek(target)).toBe(image); + }); +}); + +describe("project favicon image loader", () => { + const svg = + ''; + const svgBase64 = btoa(svg); + + function loader(response: Response, downscale = vi.fn(async () => replacement)) { + return { + downscale, + load: createProjectFaviconImageLoader({ fetch: async () => response, downscale }), + }; + } + + it("inlines small icons exactly as served without rasterizing", async () => { + const { load, downscale } = loader( + new Response(svg, { headers: { "content-type": "image/svg+xml; charset=utf-8" } }), + ); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + expect(downscale).not.toHaveBeenCalled(); + }); + + it("falls back to the file extension when the response has no image type", async () => { + const { load } = loader(new Response(svg, { headers: { "content-type": "text/plain" } })); + expect(await load(url, signal())).toBe(`data:image/svg+xml;base64,${svgBase64}`); + }); + + it("downscales large bitmaps and refuses large vector icons", async () => { + const bytes = new Uint8Array(PROJECT_FAVICON_MAX_DATA_URL_LENGTH); + const bitmap = loader(new Response(bytes, { headers: { "content-type": "image/png" } })); + expect(await bitmap.load("https://remote.test/api/assets/t/v1-icon.png", signal())).toBe( + replacement, + ); + expect(bitmap.downscale).toHaveBeenCalledWith( + expect.objectContaining({ mimeType: "image/png", bytes }), + expect.any(AbortSignal), + ); + const vector = loader(new Response(bytes, { headers: { "content-type": "image/svg+xml" } })); + await expect(vector.load(url, signal())).rejects.toThrow("exceeds the cache limit"); + expect(vector.downscale).not.toHaveBeenCalled(); + }); + + it("stops reading a response that exceeds the source limit", async () => { + let pulled = 0; + const chunk = new Uint8Array(1024 * 1024); + const stream = new ReadableStream({ + pull(controller) { + pulled += 1; + controller.enqueue(chunk); + }, + }); + const { load, downscale } = loader( + new Response(stream, { headers: { "content-type": "image/png" } }), + ); + await expect(load(url, signal())).rejects.toThrow("too large"); + expect(pulled).toBeLessThan(PROJECT_FAVICON_MAX_SOURCE_BYTES / chunk.byteLength + 3); + expect(downscale).not.toHaveBeenCalled(); + const declared = loader( + new Response("x", { + headers: { "content-type": "image/png", "content-length": String(2 ** 40) }, + }), + ); + await expect(declared.load(url, signal())).rejects.toThrow("too large"); + }); + + it("rejects failed responses and non-image payloads", async () => { + const failed = loader(new Response("nope", { status: 404 })); + await expect(failed.load(url, signal())).rejects.toThrow("404"); + const html = loader(new Response("", { headers: { "content-type": "text/html" } })); + await expect( + html.load("https://remote.test/api/assets/t/v1-favicon", signal()), + ).rejects.toThrow("no image type"); + }); +}); diff --git a/packages/client-runtime/src/projectFaviconCache.ts b/packages/client-runtime/src/projectFaviconCache.ts new file mode 100644 index 000000000000..53fd58372190 --- /dev/null +++ b/packages/client-runtime/src/projectFaviconCache.ts @@ -0,0 +1,263 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { mediaMimeType } from "@t3tools/shared/filePreview"; +import { + getProjectFaviconCacheKey, + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Encoding from "effect/Encoding"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +export const PROJECT_FAVICON_THUMBNAIL_SIZE = 96; +export const PROJECT_FAVICON_MAX_DATA_URL_LENGTH = 32 * 1024; +/** Larger sources are not worth decoding for an icon and are left to the remote URL. */ +export const PROJECT_FAVICON_MAX_SOURCE_BYTES = 4 * 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_BYTES = 1024 * 1024; +export const PROJECT_FAVICON_CACHE_MAX_ENTRIES = 128; + +export interface ProjectFaviconTarget { + readonly environmentId: EnvironmentId; + readonly cwd: string; + readonly faviconPath?: string | null | undefined; +} + +const ImageDataUrl = Schema.String.check( + Schema.isMaxLength(PROJECT_FAVICON_MAX_DATA_URL_LENGTH), + Schema.isPattern( + /^data:image\/(?:png|jpeg|gif|webp|avif|svg\+xml|x-icon|vnd\.microsoft\.icon);base64,[A-Za-z0-9+/]+={0,2}$/, + ), +); +const Entry = Schema.Struct({ + environmentId: EnvironmentId, + cwd: Schema.String, + faviconPath: Schema.NullOr(Schema.String), + revision: Schema.String, + dataUrl: ImageDataUrl, +}); +export type ProjectFaviconEntry = typeof Entry.Type; +const decodeEntry = Schema.decodeUnknownOption(Entry); +const isImageDataUrl = Schema.is(ImageDataUrl); + +function keyFor(target: ProjectFaviconTarget) { + return getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath); +} + +export interface ProjectFaviconStorage { + /** Every persisted record; entries that fail validation are ignored. */ + readonly list: () => Promise>; + readonly put: (key: string, entry: ProjectFaviconEntry) => Promise; + readonly remove: (key: string, entry: ProjectFaviconEntry) => Promise; +} + +async function readBounded(response: Response, maxBytes: number) { + const declared = Number(response.headers.get("content-length")); + if (declared > maxBytes) throw new Error("Project icon is too large to decode."); + if (!response.body) { + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength > maxBytes) throw new Error("Project icon is too large to decode."); + return bytes; + } + const reader = response.body.getReader(); + const chunks: Array = []; + let total = 0; + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + total += value.byteLength; + if (total > maxBytes) throw new Error("Project icon is too large to decode."); + chunks.push(value); + } + } finally { + reader.cancel().catch(() => {}); + } + const bytes = new Uint8Array(new ArrayBuffer(total)); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +/** + * Fetches an icon and inlines its bytes when they fit the cache limit, so SVGs + * and small bitmaps are stored exactly as served. Larger bitmaps go through the + * platform downscaler; larger SVGs stay remote because rasterizing them without + * intrinsic dimensions is unreliable. + */ +export function createProjectFaviconImageLoader(input: { + readonly fetch?: typeof fetch; + readonly downscale: ( + image: { + readonly url: string; + readonly mimeType: string; + readonly bytes: Uint8Array; + }, + signal: AbortSignal, + ) => Promise; +}) { + const fetchImpl = input.fetch ?? globalThis.fetch; + return async (url: string, signal: AbortSignal): Promise => { + const response = await fetchImpl(url, { signal }); + if (!response.ok) throw new Error(`Project icon request failed with ${response.status}.`); + const contentType = response.headers + .get("content-type") + ?.split(";", 1)[0] + ?.trim() + .toLowerCase(); + const mimeType = contentType?.startsWith("image/") ? contentType : mediaMimeType(url); + if (!mimeType) throw new Error("Project icon has no image type."); + const bytes = await readBounded(response, PROJECT_FAVICON_MAX_SOURCE_BYTES); + signal.throwIfAborted(); + const dataUrl = `data:${mimeType};base64,${Encoding.encodeBase64(bytes)}`; + if (isImageDataUrl(dataUrl)) return dataUrl; + if (mimeType === "image/svg+xml") throw new Error("Project icon exceeds the cache limit."); + return input.downscale({ url, mimeType, bytes }, signal); + }; +} + +/** Stores small, self-contained images so startup never needs an old signed URL. */ +export function createProjectFaviconCache(input: { + readonly storage: ProjectFaviconStorage; + readonly load: (url: string, signal: AbortSignal) => Promise; +}) { + const entries = new Map(); + const environmentRevisions = new Map(); + let generation = 0; + let hydration: Promise | undefined; + let clearing: Promise | undefined; + const pending = new Set>(); + + const persist = (operation: () => Promise) => { + const task: Promise = operation() + .catch(() => { + // Keep the in-memory image if local storage is full or unavailable. + }) + .finally(() => pending.delete(task)); + pending.add(task); + }; + + const remove = (key: string) => { + const entry = entries.get(key); + if (!entry) return; + entries.delete(key); + persist(() => input.storage.remove(key, entry)); + }; + + const trim = () => { + let bytes = 0; + for (const entry of entries.values()) bytes += entry.dataUrl.length; + while ( + entries.size > PROJECT_FAVICON_CACHE_MAX_ENTRIES || + bytes > PROJECT_FAVICON_CACHE_MAX_BYTES + ) { + const oldest = entries.entries().next().value; + if (!oldest) break; + bytes -= oldest[1].dataUrl.length; + remove(oldest[0]); + } + }; + + const hydrate = () => + (hydration ??= (async () => { + try { + for (const record of await input.storage.list()) { + const entry = decodeEntry(record); + if (Option.isSome(entry)) entries.set(keyFor(entry.value), entry.value); + } + trim(); + } catch { + // A missing, corrupt, or unavailable cache must not prevent startup. + } + })()); + + const peek = (target: ProjectFaviconTarget) => entries.get(keyFor(target))?.dataUrl ?? null; + + const resolve = async ( + target: ProjectFaviconTarget, + url: string | null, + signal: AbortSignal, + ): Promise => { + await clearing; + const startGeneration = generation; + const startRevision = environmentRevisions.get(target.environmentId) ?? 0; + await hydrate(); + if (signal.aborted || url === null) return peek(target); + const key = keyFor(target); + if (isProjectFaviconFallbackUrl(url)) { + remove(key); + return null; + } + const revision = getProjectFaviconCacheKey(target.environmentId, target.cwd, url); + const cached = entries.get(key); + if (cached) { + entries.delete(key); + entries.set(key, cached); + if (cached.revision === revision) return cached.dataUrl; + } + try { + const dataUrl = await input.load(url, signal); + if ( + signal.aborted || + startGeneration !== generation || + startRevision !== (environmentRevisions.get(target.environmentId) ?? 0) + ) { + return peek(target); + } + if (isImageDataUrl(dataUrl)) { + const entry = { + environmentId: target.environmentId, + cwd: target.cwd, + faviconPath: target.faviconPath || null, + revision, + dataUrl, + }; + entries.set(key, entry); + persist(() => input.storage.put(key, entry)); + trim(); + return dataUrl; + } + } catch { + // An outage or failed decode leaves the last successful image visible. + } + return peek(target) ?? url; + }; + + const flush = async () => { + await Promise.all(pending); + }; + + // A download that started before the clear sees the revision change and is discarded; + // one that starts during the clear waits for it, so it cannot repopulate storage. + const clear = async (environmentId?: EnvironmentId) => { + if (environmentId === undefined) generation += 1; + else + environmentRevisions.set(environmentId, (environmentRevisions.get(environmentId) ?? 0) + 1); + const previous = clearing; + const task = (async () => { + await previous; + await hydrate(); + for (const [key, entry] of entries) { + if (environmentId === undefined || entry.environmentId === environmentId) remove(key); + } + await flush(); + })().finally(() => { + if (clearing === task) clearing = undefined; + }); + clearing = task; + await task; + }; + + return { + hydrate, + peek, + resolve, + clearEnvironment: (environmentId: EnvironmentId) => clear(environmentId), + clearAll: () => clear(), + flush, + }; +} + +export type ProjectFaviconCache = ReturnType; diff --git a/packages/client-runtime/src/state/assets.test.ts b/packages/client-runtime/src/state/assets.test.ts index d75e82281382..1cbc970df928 100644 --- a/packages/client-runtime/src/state/assets.test.ts +++ b/packages/client-runtime/src/state/assets.test.ts @@ -1,11 +1,15 @@ import { describe, expect, it } from "@effect/vitest"; -import { EnvironmentId } from "@t3tools/contracts"; +import { type AssetCreateUrlResult, EnvironmentId } from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; import * as Layer from "effect/Layer"; -import { Atom } from "effect/unstable/reactivity"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { createProjectFaviconCache } from "../projectFaviconCache.ts"; import { createAssetEnvironmentAtoms, + createProjectFaviconUrlAtomFamily, InvalidAssetCollectionKeyError, parseAssetCollectionKey, } from "./assets.ts"; @@ -118,3 +122,160 @@ describe("createAssetEnvironmentAtoms", () => { ).not.toBe(assets.createUrls({ environmentId, resources })); }); }); + +describe("project favicon URL cache", () => { + it("renders a persisted thumbnail immediately in a fresh registry and refreshes it remotely", async () => { + const image = "data:image/png;base64,aWNvbg=="; + const replacement = "data:image/png;base64,bmV3"; + const records = new Map(); + const storage = { + list: async () => [...records.values()], + put: async (key: string, entry: unknown) => { + records.set(key, entry); + }, + remove: async (key: string) => { + records.delete(key); + }, + }; + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const previousCache = createProjectFaviconCache({ storage, load: async () => image }); + await previousCache.resolve( + target, + "https://remote.test/api/assets/old/v1-icon.png", + new AbortController().signal, + ); + await previousCache.flush(); + const cache = createProjectFaviconCache({ storage, load: async () => replacement }); + await cache.hydrate(); + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make>(Option.none()); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + imageCache: cache, + })(target); + const unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBe(image); + let unsubscribe = () => {}; + const refreshed = new Promise((resolve) => { + unsubscribe = registry.subscribe(favicon, (value) => { + if (value === replacement) resolve(); + }); + }); + registry.set(connection, Option.some({ httpBaseUrl: "https://remote.test" })); + registry.set( + result, + AsyncResult.success({ + relativeUrl: "/api/assets/new/v2-icon.png", + expiresAt: 4_000_000_000_000, + }), + ); + expect(registry.get(favicon)).toBe(image); + await refreshed; + unsubscribe(); + expect(registry.get(favicon)).toBe(replacement); + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("offline"))); + expect(registry.get(favicon)).toBe(replacement); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("retains icons across outages and remounts, then accepts refreshed and missing icons", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.initial(), + ); + const connection = Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => connection, + })({ environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }); + let unmount = registry.mount(favicon); + try { + expect(registry.get(favicon)).toBeNull(); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-a/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(connection, Option.none()); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + unmount(); + unmount = registry.mount(favicon); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + + registry.set(result, AsyncResult.initial()); + registry.set(connection, Option.some({ httpBaseUrl: "https://reconnected.test" })); + expect(registry.get(favicon)).toBe("https://remote.test/api/assets/token-a/icon.svg"); + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-b/icon.svg", + }), + ); + expect(registry.get(favicon)).toBe("https://reconnected.test/api/assets/token-b/icon.svg"); + + registry.set( + result, + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token-c/project-favicon-missing", + }), + ); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + registry.set(connection, Option.none()); + expect(registry.get(favicon)).toBe( + "https://reconnected.test/api/assets/token-c/project-favicon-missing", + ); + } finally { + unmount(); + registry.dispose(); + } + }); + + it("does not reuse another environment, workspace, or selected icon's cached URL", () => { + const registry = AtomRegistry.make(); + const result = Atom.make>( + AsyncResult.success({ + expiresAt: 4_000_000_000_000, + relativeUrl: "/api/assets/token/icon.svg", + }), + ); + const favicon = createProjectFaviconUrlAtomFamily({ + createUrl: () => result, + preparedConnection: () => Atom.make(Option.some({ httpBaseUrl: "https://remote.test" })), + }); + const target = { environmentId: EnvironmentId.make("remote"), cwd: "/workspace" }; + const unmount = registry.mount(favicon(target)); + try { + expect(registry.get(favicon(target))).toBe("https://remote.test/api/assets/token/icon.svg"); + registry.set(result, AsyncResult.failure(Cause.die("disconnected"))); + expect( + registry.get(favicon({ ...target, environmentId: EnvironmentId.make("other") })), + ).toBeNull(); + expect(registry.get(favicon({ ...target, cwd: "/other" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: "brand.svg" }))).toBeNull(); + expect(registry.get(favicon({ ...target, faviconPath: null }))).toBe( + "https://remote.test/api/assets/token/icon.svg", + ); + } finally { + unmount(); + registry.dispose(); + } + }); +}); diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 0030cec6d0c5..b8646d911cc2 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -5,10 +5,17 @@ import { EnvironmentId, WS_METHODS, } from "@t3tools/contracts"; +import { + getProjectFaviconResourceKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import type { EnvironmentRegistry } from "../connection/registry.ts"; +import type { ProjectFaviconCache, ProjectFaviconTarget } from "../projectFaviconCache.ts"; import { createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; const ASSET_URL_REFRESH_INTERVAL_MS = 30 * 60_000; @@ -118,3 +125,53 @@ export function createAssetEnvironmentAtoms( }) => createUrlsFamily(JSON.stringify([target.environmentId, target.resources])), }; } + +/** + * Keeps project icons visible while their environment reconnects. Each resource + * owns its last resolved URL, including a confirmed missing-icon response. + */ +export function createProjectFaviconUrlAtomFamily(input: { + readonly imageCache?: ProjectFaviconCache; + readonly createUrl: (target: { + readonly environmentId: EnvironmentId; + readonly input: { readonly resource: AssetResource }; + }) => Atom.Atom>; + readonly preparedConnection: ( + environmentId: EnvironmentId, + ) => Atom.Atom>; +}) { + const decodeKey = Schema.decodeUnknownSync( + Schema.Tuple([EnvironmentId, Schema.String, Schema.NullOr(Schema.String)]), + ); + const family = Atom.family((key: string) => { + const [environmentId, cwd, path] = decodeKey(JSON.parse(key)); + const resource = { _tag: "project-favicon" as const, cwd, ...(path ? { path } : {}) }; + const request = input.createUrl({ environmentId, input: { resource } }); + const resolvedUrl = Atom.make((get): string | null => { + const result = get(request); + const connection = get(input.preparedConnection(environmentId)); + const state = assetUrlStateFromResult( + result, + Option.isSome(connection) ? connection.value.httpBaseUrl : null, + ); + return state._tag === "Success" ? state.url : Option.getOrNull(get.self()); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + const cache = input.imageCache; + if (!cache) return resolvedUrl; + + const target = { environmentId, cwd, faviconPath: path }; + const image = Atom.make((get) => { + get(request); + const url = get(resolvedUrl); + return Effect.promise((signal) => cache.resolve(target, url, signal)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + + return Atom.make((get): string | null => { + const result = get(image); + if (isProjectFaviconFallbackUrl(get(resolvedUrl))) return null; + return Option.getOrElse(AsyncResult.value(result), () => cache.peek(target)); + }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); + }); + return (target: ProjectFaviconTarget) => + family(getProjectFaviconResourceKey(target.environmentId, target.cwd, target.faviconPath)); +} diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b63..b6fd9e56f3a6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,13 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconResourceKey( + environmentId: string, + workspaceRoot: string, + faviconPath?: string | null, +) { + return JSON.stringify([environmentId, workspaceRoot, faviconPath || null]); +} + export function getProjectFaviconCacheKey( environmentId: string, workspaceRoot: string, From b438447f67b6b61bbe6f564d8b78fd90702117c5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 19:22:39 -0700 Subject: [PATCH 166/320] fix(mobile): use selected theme across input forms and controls (#10239) --- ...rated-uniwind-default-theme-variables.json | 6 +++ apps/mobile/generated-uniwind-themes.css | 30 +++++++++++++ apps/mobile/global.css | 10 +++++ apps/mobile/src/components/AppText.tsx | 2 + .../components/ComposerAttachmentStrip.tsx | 2 +- apps/mobile/src/components/ErrorBanner.tsx | 4 +- .../connection/CloudEnvironmentRows.tsx | 2 +- .../connection/ConnectionEnvironmentRow.tsx | 2 +- .../src/features/connection/connectionTone.ts | 20 ++++----- .../features/files/ThreadFilesRouteScreen.tsx | 6 +-- .../features/home/thread-swipe-actions.tsx | 33 ++++++++++---- .../src/features/review/ReviewSheet.tsx | 8 ++-- .../components/FontSizeSliderRow.tsx | 3 +- .../threads/GitActionProgressOverlay.tsx | 8 ++-- .../features/threads/PendingApprovalCard.tsx | 24 +++++------ .../features/threads/PendingUserInputCard.tsx | 43 ++++++++++--------- .../src/features/threads/ThreadFeed.tsx | 19 +++++--- .../features/threads/git/GitCommitSheet.tsx | 2 +- .../features/threads/thread-list-items.tsx | 4 +- .../features/threads/thread-list-v2-items.tsx | 23 ++++------ .../features/threads/thread-search-match.tsx | 2 +- .../src/features/threads/thread-work-log.tsx | 6 +-- .../features/threads/threadPresentation.ts | 24 +++++------ apps/mobile/src/lib/mobileTheme.test.ts | 8 +++- apps/mobile/src/lib/mobileTheme.ts | 3 ++ .../src/state/thread-pr-presentation.ts | 4 +- apps/mobile/src/state/use-thread-pr.test.ts | 2 +- 27 files changed, 184 insertions(+), 116 deletions(-) diff --git a/apps/mobile/generated-uniwind-default-theme-variables.json b/apps/mobile/generated-uniwind-default-theme-variables.json index 427d370acb57..1953880fa946 100644 --- a/apps/mobile/generated-uniwind-default-theme-variables.json +++ b/apps/mobile/generated-uniwind-default-theme-variables.json @@ -28,6 +28,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(0, 0, 0, 0.08)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "#fffbeb", + "--color-warning-border": "#fde68a", + "--color-warning-foreground": "#b45309", "--color-danger": "#fef2f2", "--color-danger-border": "rgba(239, 68, 68, 0.12)", "--color-danger-foreground": "#dc2626", @@ -95,6 +98,9 @@ "--color-switch-active-thumb": "#ffffff", "--color-switch-inactive-track": "rgba(255, 255, 255, 0.06)", "--color-switch-inactive-thumb": "#8e8e93", + "--color-warning": "rgba(69, 26, 3, 0.4)", + "--color-warning-border": "rgba(120, 53, 15, 0.6)", + "--color-warning-foreground": "#fcd34d", "--color-danger": "rgba(239, 68, 68, 0.14)", "--color-danger-border": "rgba(248, 113, 113, 0.18)", "--color-danger-foreground": "#fca5a5", diff --git a/apps/mobile/generated-uniwind-themes.css b/apps/mobile/generated-uniwind-themes.css index 8ba542165f18..e9352e1b38b9 100644 --- a/apps/mobile/generated-uniwind-themes.css +++ b/apps/mobile/generated-uniwind-themes.css @@ -150,6 +150,9 @@ --color-switch-active-thumb: #ffffff; --color-switch-inactive-track: #f1c4e6; --color-switch-inactive-thumb: #8d1255; + --color-warning: #fcf0ea; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #b05109; --color-danger: #fde4f1; --color-danger-border: rgba(247, 8, 108, 0.32); --color-danger-foreground: #9d174d; @@ -275,6 +278,9 @@ --color-switch-active-thumb: #fbd0e8; --color-switch-inactive-track: #362d3d; --color-switch-inactive-thumb: #e7d0dd; + --color-warning: #412f20; + --color-warning-border: rgba(245, 158, 11, 0.32); + --color-warning-foreground: #fbbf24; --color-danger: #331a2b; --color-danger-border: rgba(157, 23, 77, 0.32); --color-danger-foreground: #fbd0e8; @@ -400,6 +406,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e2ede7; --color-switch-inactive-thumb: #6e696f; + --color-warning: #f4f0e1; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b64a00; --color-danger: #f4e7e5; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -525,6 +534,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #2a4b39; --color-switch-inactive-thumb: #9da5a2; + --color-warning: #3f3a1c; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3f2c28; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6668; @@ -650,6 +662,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #e4ecf2; --color-switch-inactive-thumb: #6f6873; + --color-warning: #f6efe4; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b74b00; --color-danger: #f5e6e9; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -775,6 +790,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #293f52; --color-switch-inactive-thumb: #969ca6; + --color-warning: #3c3424; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #3c2630; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -900,6 +918,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #f3eae5; --color-switch-inactive-thumb: #74686f; + --color-warning: #f9efe2; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f9e7e6; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1025,6 +1046,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #513728; --color-switch-inactive-thumb: #a59996; + --color-warning: #4b3215; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #4a2321; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; @@ -1150,6 +1174,9 @@ --color-switch-active-thumb: #fffaff; --color-switch-inactive-track: #edeaf4; --color-switch-inactive-thumb: #726874; + --color-warning: #f8efe5; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #b84b00; --color-danger: #f8e6ea; --color-danger-border: rgba(251, 44, 54, 0.32); --color-danger-foreground: #c10007; @@ -1275,6 +1302,9 @@ --color-switch-active-thumb: #241523; --color-switch-inactive-track: #362d51; --color-switch-inactive-thumb: #9690a1; + --color-warning: #412e23; + --color-warning-border: rgba(254, 154, 0, 0.32); + --color-warning-foreground: #ffb900; --color-danger: #40202e; --color-danger-border: rgba(251, 65, 74, 0.32); --color-danger-foreground: #ff6467; diff --git a/apps/mobile/global.css b/apps/mobile/global.css index e6961eac4eea..e153107b1811 100644 --- a/apps/mobile/global.css +++ b/apps/mobile/global.css @@ -52,6 +52,11 @@ --color-switch-inactive-track: rgba(0, 0, 0, 0.08); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: #fffbeb; + --color-warning-border: #fde68a; + --color-warning-foreground: #b45309; + /* Danger */ --color-danger: #fef2f2; --color-danger-border: rgba(239, 68, 68, 0.12); @@ -151,6 +156,11 @@ --color-switch-inactive-track: rgba(255, 255, 255, 0.06); --color-switch-inactive-thumb: #8e8e93; + /* Warning */ + --color-warning: rgba(69, 26, 3, 0.4); + --color-warning-border: rgba(120, 53, 15, 0.6); + --color-warning-foreground: #fcd34d; + /* Danger */ --color-danger: rgba(239, 68, 68, 0.14); --color-danger-border: rgba(248, 113, 113, 0.18); diff --git a/apps/mobile/src/components/AppText.tsx b/apps/mobile/src/components/AppText.tsx index 39517f0e62ee..6501d2044083 100644 --- a/apps/mobile/src/components/AppText.tsx +++ b/apps/mobile/src/components/AppText.tsx @@ -35,6 +35,8 @@ export function AppTextInput({ className, ref, ...props }: AppTextInputProps) { className, )} placeholderTextColorClassName="accent-placeholder" + selectionColorClassName="accent-foreground-secondary" + cursorColorClassName="accent-foreground-secondary" {...props} /> ); diff --git a/apps/mobile/src/components/ComposerAttachmentStrip.tsx b/apps/mobile/src/components/ComposerAttachmentStrip.tsx index 40465012e5da..8c86fcc38e69 100644 --- a/apps/mobile/src/components/ComposerAttachmentStrip.tsx +++ b/apps/mobile/src/components/ComposerAttachmentStrip.tsx @@ -195,7 +195,7 @@ function ComposerAttachmentContent(props: ComposerAttachmentThumbnailProps) { {!props.compact ? ( diff --git a/apps/mobile/src/components/ErrorBanner.tsx b/apps/mobile/src/components/ErrorBanner.tsx index 6c12c9bdd823..38f85de195b5 100644 --- a/apps/mobile/src/components/ErrorBanner.tsx +++ b/apps/mobile/src/components/ErrorBanner.tsx @@ -3,8 +3,8 @@ import { View } from "react-native"; import { AppText as Text } from "./AppText"; export function ErrorBanner(props: { readonly message: string }) { return ( - - {props.message} + + {props.message} ); } diff --git a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx index 448889549016..806499c2273b 100644 --- a/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx +++ b/apps/mobile/src/features/connection/CloudEnvironmentRows.tsx @@ -299,7 +299,7 @@ function CloudEnvironmentRowShell(props: { traceId: props.connectionErrorTraceId, }); const statusClassName = props.connectionError - ? "text-adaptive-rose-500-400" + ? "text-danger-foreground" : "text-foreground-muted"; const [errorMeasurement, setErrorMeasurement] = useState<{ readonly text: string; diff --git a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx index 75d3e8ce7a34..5555548ff799 100644 --- a/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx +++ b/apps/mobile/src/features/connection/ConnectionEnvironmentRow.tsx @@ -96,7 +96,7 @@ export function ConnectionEnvironmentRow(props: { {props.truncated ? ( - - + + Partial file - + Preview limited to the first 1 MB of a truncated file. diff --git a/apps/mobile/src/features/home/thread-swipe-actions.tsx b/apps/mobile/src/features/home/thread-swipe-actions.tsx index 052ac969c10f..c5f6768c55d0 100644 --- a/apps/mobile/src/features/home/thread-swipe-actions.tsx +++ b/apps/mobile/src/features/home/thread-swipe-actions.tsx @@ -62,7 +62,7 @@ interface ThreadSwipeAction { } interface ThreadSwipeSecondaryAction extends ThreadSwipeAction { - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; } function swipeActionsWidth(hasSecondaryAction: boolean) { @@ -80,7 +80,7 @@ function resolveSecondaryAction(input: { if (input.secondaryAction === undefined) { return { accessibilityLabel: `Delete ${input.threadTitle}`, - backgroundColor: "#ff2d55", + tone: "danger", icon: "trash", label: "Delete", onPress: () => { @@ -92,7 +92,7 @@ function resolveSecondaryAction(input: { const action = input.secondaryAction; return { ...action, - backgroundColor: "#5856d6", + tone: "secondary", menu: action.menu === undefined ? undefined @@ -359,7 +359,7 @@ export function ThreadSwipeable(props: { function SwipeActionButton(props: { readonly accessibilityLabel: string; readonly actionsWidth: number; - readonly backgroundColor: string; + readonly tone: "primary" | "secondary" | "danger"; readonly compact: boolean; readonly entryRange: readonly [number, number]; readonly fullSwipeThreshold: number; @@ -462,9 +462,15 @@ function SwipeActionButton(props: { > - + - - Partial diff - - {props.notice} + + Partial diff + {props.notice} ); }); diff --git a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx index 9b5d8e113f85..bc8ddfc84ba5 100644 --- a/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx +++ b/apps/mobile/src/features/settings/appearance/components/FontSizeSliderRow.tsx @@ -176,10 +176,9 @@ export function FontSizeSliderRow(props: { /> + diff --git a/apps/mobile/src/features/threads/PendingApprovalCard.tsx b/apps/mobile/src/features/threads/PendingApprovalCard.tsx index a94f321a4ad8..4be6ff611842 100644 --- a/apps/mobile/src/features/threads/PendingApprovalCard.tsx +++ b/apps/mobile/src/features/threads/PendingApprovalCard.tsx @@ -30,22 +30,20 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { // Opaque for the same reason as PendingUserInputCard: nothing blurs the feed // behind this card, so a translucent surface bleeds messages through it. return ( - - + + Approval needed - + {props.approval.appName ?? props.approval.requestKind} {props.approval.detail ? ( - + {props.approval.detail} ) : null} {warning ? ( - - {warning} - + {warning} ) : null} {options.map((option) => ( @@ -53,10 +51,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { key={option.decision} className={`items-center justify-center rounded-[14px] px-3.5 py-3 ${ option.decision === "accept" - ? "bg-blue-500" + ? "bg-primary" : option.decision === "decline" - ? "bg-adaptive-rose-100-500-a18" - : "bg-adaptive-neutral-200-800" + ? "bg-danger" + : "bg-subtle-strong" }`} disabled={props.respondingApprovalId === props.approval.requestId} onPress={() => void props.onRespond(props.approval.requestId, option.decision)} @@ -64,10 +62,10 @@ export function PendingApprovalCard(props: PendingApprovalCardProps) { {option.label} diff --git a/apps/mobile/src/features/threads/PendingUserInputCard.tsx b/apps/mobile/src/features/threads/PendingUserInputCard.tsx index f821c5714950..8fe7fc186adb 100644 --- a/apps/mobile/src/features/threads/PendingUserInputCard.tsx +++ b/apps/mobile/src/features/threads/PendingUserInputCard.tsx @@ -161,7 +161,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { pointerEvents={props.collapsed ? "auto" : "none"} accessibilityElementsHidden={!props.collapsed} importantForAccessibility={props.collapsed ? "auto" : "no-hide-descendants"} - className="flex-row items-center gap-2 rounded-full border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 py-1.5 pl-4 pr-1.5" + className="flex-row items-center gap-2 rounded-full border border-border bg-card-alt py-1.5 pl-4 pr-1.5" > - + User input needed - + {questionCount} question{questionCount === 1 ? "" : "s"} @@ -216,7 +216,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { : FadeOutDown.duration(USER_INPUT_TOGGLE_DURATION_MS).easing(Easing.out(Easing.cubic)) } layout={CARD_LAYOUT_TRANSITION} - className="overflow-hidden gap-2.5 rounded-[20px] border border-adaptive-neutral-200-white-a6 bg-adaptive-neutral-100-900 p-4" + className="overflow-hidden gap-2.5 rounded-[20px] border border-border bg-card-alt p-4" style={ EXPANDED_CARD_IS_OVERLAY ? [{ maxHeight: props.maxHeight }, cardAnimatedStyle] @@ -230,14 +230,12 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { className="flex-row items-start gap-2" > - + User input needed - - Fill in the pending answers - + Fill in the pending answers - + - + {question.header} - + {question.question} @@ -276,9 +274,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { key={optionValue} className={cn( "min-h-12 w-full rounded-2xl border px-3.5 py-3", - selected - ? "border-adaptive-blue-300-a50-blue-400-a28 bg-adaptive-blue-50-blue-400-a14" - : "border-adaptive-neutral-200-white-a6 bg-adaptive-white-neutral-950-a70", + selected ? "border-primary bg-primary/10" : "border-border bg-input", )} onPress={() => props.onSelectOption( @@ -292,15 +288,13 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { {option.label} {description ? ( - + {description} ) : null} @@ -318,7 +312,7 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { onFocus={() => props.onInputFocusChange?.(true)} onBlur={() => props.onInputFocusChange?.(false)} placeholder="Or type a custom answer" - className="min-h-[54px] rounded-2xl border border-adaptive-neutral-200-white-a8 bg-adaptive-white-neutral-950-a70 px-3.5 py-3 font-sans text-base text-adaptive-neutral-950-50" + className="min-h-[54px] rounded-2xl border border-input-border bg-input px-3.5 py-3 font-sans text-base text-foreground" /> ) : null} @@ -328,14 +322,21 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) { void props.onSubmit()} > - Submit answers + + Submit answers + ) : null; diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index d53ae65be062..b57758b50c12 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -504,7 +504,12 @@ function MessageAttachmentFile(props: { function MessageAttachmentUnknown(props: { readonly name: string }) { return ( - + {props.name} @@ -1352,7 +1357,7 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2" style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), }} @@ -1406,7 +1411,7 @@ function renderFeedEntry( accessibilityLabel={label} className="mb-3 flex-row items-center gap-3 px-1 py-1" > - + {label} - + ); } @@ -1503,7 +1508,7 @@ function renderFeedEntry( })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1552,7 +1557,7 @@ function renderFeedEntry( attachmentId={attachment.id} name={attachment.name} mimeType={attachment.mimeType} - className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong" onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( @@ -1576,7 +1581,7 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - + {timestampLabel} diff --git a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx index f263372bad22..e76672de20f1 100644 --- a/apps/mobile/src/features/threads/git/GitCommitSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitCommitSheet.tsx @@ -85,7 +85,7 @@ export function GitCommitSheet(_props: GitCommitSheetProps) { {isDefaultRef ? ( - + Warning: this is the default branch. ) : null} diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index fcba4626be2d..fc3898279e3e 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -297,8 +297,8 @@ export const PendingTaskListRow = memo(function PendingTaskListRow(props: { ); const statusPill = ( - - Pending + + Pending ); 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 97c13de56aab..e34dce059014 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -23,7 +23,6 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { useThreadPr } from "../../state/use-thread-pr"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { buildThreadTitleRegenerationMenuItems } from "./thread-title-regeneration-menu"; import { resolveThreadListV2SnoozeMenuSelection, @@ -53,10 +52,10 @@ const MONO_FONT = Platform.select({ const STATUS_LABEL_BY_STATUS: Partial< Record > = { - approval: { label: "Approval", className: "text-adaptive-amber-700-300" }, - input: { label: "Input", className: "text-adaptive-indigo-600-300" }, - working: { label: "Working", className: "text-adaptive-sky-600-400" }, - failed: { label: "Failed", className: "text-adaptive-red-700-300" }, + approval: { label: "Approval", className: "text-warning-foreground" }, + input: { label: "Input", className: "text-foreground-secondary" }, + working: { label: "Working", className: "text-foreground-secondary" }, + failed: { label: "Failed", className: "text-danger-foreground" }, }; function threadTimeLabel(thread: EnvironmentThreadShell): string { @@ -107,9 +106,6 @@ export const ThreadListV2SectionDivider = memo(function ThreadListV2SectionDivid ); }); -const SNOOZE_ACCENT_LIGHT = "#2563eb"; -const SNOOZE_ACCENT_DARK = "#60a5fa"; - export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedShelfHeader(props: { readonly count: number; readonly disabled?: boolean; @@ -117,7 +113,6 @@ export const ThreadListV2SnoozedShelfHeader = memo(function ThreadListV2SnoozedS readonly onToggle: () => void; readonly pane?: "screen" | "sidebar"; }) { - const { themeAppearance: colorScheme } = useAppearancePreferences(); return ( ({ opacity: pressed ? 0.6 : 1 })} > - + {props.expanded ? "Snoozed" : `Snoozed (${props.count})`} - + @@ -737,7 +732,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { @@ -919,7 +914,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { selected ? "text-user-bubble-foreground-muted" : snoozedRow - ? "text-adaptive-blue-600-400" + ? "text-foreground-secondary" : "text-foreground-tertiary", )} style={{ fontFamily: MONO_FONT }} diff --git a/apps/mobile/src/features/threads/thread-search-match.tsx b/apps/mobile/src/features/threads/thread-search-match.tsx index 48aaf80249d5..9c478f3c4504 100644 --- a/apps/mobile/src/features/threads/thread-search-match.tsx +++ b/apps/mobile/src/features/threads/thread-search-match.tsx @@ -65,7 +65,7 @@ export function ThreadSearchMatchExcerpt(props: { props.selected ? "text-user-bubble-foreground" : isUser - ? "text-adaptive-blue-500-400" + ? "text-foreground-secondary" : "text-adaptive-emerald-600-400", )} > diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 13a78b8f1454..9d5b40fce6dc 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -773,7 +773,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( color={props.iconSubtleColor} colorClassName={ iconIsDestructive - ? "accent-adaptive-rose-600-400" + ? "accent-danger-foreground" : failed ? "accent-danger-foreground/40" : undefined @@ -784,7 +784,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( @@ -832,7 +832,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" + className="ml-7 border-l border-border pb-1 pl-3 pt-0.5" > {viewedImagePath ? ( diff --git a/apps/mobile/src/features/threads/threadPresentation.ts b/apps/mobile/src/features/threads/threadPresentation.ts index 59cf108a01dd..bfc72d2ac9b8 100644 --- a/apps/mobile/src/features/threads/threadPresentation.ts +++ b/apps/mobile/src/features/threads/threadPresentation.ts @@ -42,8 +42,8 @@ export function resolveThreadStatus( return { kind: "pending-approval", label: "Needs Approval", - pillClassName: "bg-adaptive-amber-500-a12-a16", - textClassName: "text-adaptive-amber-700-300", + pillClassName: "bg-warning", + textClassName: "text-warning-foreground", iconColor: "#ff9f0a", iconBackground: "rgba(255,159,10,0.22)", pulse: false, @@ -54,8 +54,8 @@ export function resolveThreadStatus( return { kind: "awaiting-input", label: "Awaiting Input", - pillClassName: "bg-adaptive-indigo-500-a12-a16", - textClassName: "text-adaptive-indigo-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#5e5ce6", iconBackground: "rgba(94,92,230,0.22)", pulse: false, @@ -66,8 +66,8 @@ export function resolveThreadStatus( return { kind: "working", label: "Working", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -78,8 +78,8 @@ export function resolveThreadStatus( return { kind: "connecting", label: "Connecting", - pillClassName: "bg-adaptive-sky-500-a12-a16", - textClassName: "text-adaptive-sky-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#0a84ff", iconBackground: "rgba(10,132,255,0.22)", pulse: true, @@ -90,8 +90,8 @@ export function resolveThreadStatus( return { kind: "error", label: "Error", - pillClassName: "bg-adaptive-rose-500-a12-a16", - textClassName: "text-adaptive-rose-700-300", + pillClassName: "bg-danger", + textClassName: "text-danger-foreground", iconColor: "#ff453a", iconBackground: "rgba(255,69,58,0.22)", pulse: false, @@ -106,8 +106,8 @@ export function resolveThreadStatus( return { kind: "plan-ready", label: "Plan Ready", - pillClassName: "bg-adaptive-violet-500-a12-a16", - textClassName: "text-adaptive-violet-700-300", + pillClassName: "bg-primary/10", + textClassName: "text-foreground-secondary", iconColor: "#bf5af2", iconBackground: "rgba(191,90,242,0.22)", pulse: false, diff --git a/apps/mobile/src/lib/mobileTheme.test.ts b/apps/mobile/src/lib/mobileTheme.test.ts index a3c6712abae8..652de9296f1f 100644 --- a/apps/mobile/src/lib/mobileTheme.test.ts +++ b/apps/mobile/src/lib/mobileTheme.test.ts @@ -153,10 +153,16 @@ describe("mobile themes", () => { it("maps semantic palette roles onto every mobile color variable", () => { const variables = createMobileThemeVariables(BUILT_IN_THEMES[0].colors, "light"); - expect(Object.keys(variables)).toHaveLength(65); + expect(Object.keys(variables)).toHaveLength(68); expect(variables["--color-sheet-solid"]).toBe( themeColorToNativeColor(BUILT_IN_THEMES[0].colors.chrome), ); + expect(variables["--color-warning"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningSurface), + ); + expect(variables["--color-warning-foreground"]).toBe( + themeColorToNativeColor(BUILT_IN_THEMES[0].colors.warningForeground), + ); expect(variables["--color-primary"]).not.toBe(variables["--color-screen"]); expect(variables["--color-primary-shadow"]).toBe("#000000"); expect(variables["--color-backdrop"]).toBe("rgba(0, 0, 0, 0.22)"); diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index 23034511287e..10ef1b58edec 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -239,6 +239,9 @@ export function createMobileThemeVariables( "--color-switch-active-thumb": c.accentForeground, "--color-switch-inactive-track": c.secondary, "--color-switch-inactive-thumb": c.mutedForeground, + "--color-warning": c.warningSurface, + "--color-warning-border": withAlpha(c.warning, 0.32), + "--color-warning-foreground": c.warningForeground, "--color-danger": c.errorSurface, "--color-danger-border": withAlpha(c.error, 0.32), "--color-danger-foreground": c.errorForeground, diff --git a/apps/mobile/src/state/thread-pr-presentation.ts b/apps/mobile/src/state/thread-pr-presentation.ts index 53d19abd95a4..fd7a171810ee 100644 --- a/apps/mobile/src/state/thread-pr-presentation.ts +++ b/apps/mobile/src/state/thread-pr-presentation.ts @@ -20,7 +20,7 @@ export interface ThreadPrPresentation { const PR_STATE_TEXT_CLASS: Record = { open: "text-adaptive-emerald-600-400", merged: "text-adaptive-violet-600-400", - closed: "text-adaptive-zinc-500-400", + closed: "text-foreground-muted", }; export function presentThreadPr( @@ -37,6 +37,6 @@ export function presentThreadPr( url: pr.url, label: String(pr.number), accessibilityLabel: `#${pr.number} ${presentation.longName} ${isDraft ? "draft" : pr.state}`, - textClassName: isDraft ? "text-adaptive-zinc-500-400" : PR_STATE_TEXT_CLASS[pr.state], + textClassName: isDraft ? "text-foreground-muted" : PR_STATE_TEXT_CLASS[pr.state], }; } diff --git a/apps/mobile/src/state/use-thread-pr.test.ts b/apps/mobile/src/state/use-thread-pr.test.ts index ddda3b1acd96..f6fddfdc5578 100644 --- a/apps/mobile/src/state/use-thread-pr.test.ts +++ b/apps/mobile/src/state/use-thread-pr.test.ts @@ -39,7 +39,7 @@ describe("presentThreadPr", () => { presentThreadPr({ ...pullRequest, state: "open", isDraft: true }, undefined), ).toMatchObject({ accessibilityLabel: "#3774 pull request draft", - textClassName: "text-adaptive-zinc-500-400", + textClassName: "text-foreground-muted", }); }); }); From bfba7781681eaa03eb465ce3d9a4ec07bf952b78 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 20:12:51 -0700 Subject: [PATCH 167/320] fix(mobile): keep the new-task draft when switching environment (#10247) Co-authored-by: Claude Code --- .../threads/new-task-flow-provider.tsx | 50 ++++++------- .../new-task-project-selection.test.ts | 75 ++++++++++++++++++- .../threads/new-task-project-selection.ts | 47 ++++++++++++ .../src/state/use-composer-drafts.test.ts | 43 +++++++++++ apps/mobile/src/state/use-composer-drafts.ts | 20 ++++- 5 files changed, 202 insertions(+), 33 deletions(-) diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 6d87f284ebda..c17e2c002a15 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -92,6 +92,7 @@ import { resolveNewTaskBranchWorktreePath, resolveNewTaskLocalWorkspaceSelection, } from "./new-task-context-presentation"; +import { resolveEnvironmentProjectMatch } from "./new-task-project-selection"; type WorkspaceMode = "local" | "worktree"; @@ -622,51 +623,44 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { ); }, [availableBranches, branchQuery]); - const setProject = useCallback( + // New-task drafts are keyed per (environment, project), so retargeting the + // composer would otherwise show the target's empty draft and strand what the + // user typed under the old key. + const carryDraftContentTo = useCallback( (project: EnvironmentProject) => { - const nextProjectKey = scopedProjectKey(project.environmentId, project.id); - const nextDraftKey = `new-task:${nextProjectKey}`; + const nextDraftKey = `new-task:${scopedProjectKey(project.environmentId, project.id)}`; if ( selectedProjectDraftKey?.startsWith("new-task:") && selectedProjectDraftKey !== nextDraftKey ) { void copyComposerDraftContentIfEmpty(selectedProjectDraftKey, nextDraftKey); } - setSelectedEnvironmentId(project.environmentId); - setSelectedProjectKey(nextProjectKey); }, [selectedProjectDraftKey], ); + const setProject = useCallback( + (project: EnvironmentProject) => { + carryDraftContentTo(project); + setSelectedEnvironmentId(project.environmentId); + setSelectedProjectKey(scopedProjectKey(project.environmentId, project.id)); + }, + [carryDraftContentTo], + ); + const selectEnvironment = useCallback( (environmentId: EnvironmentId) => { - const projectsOnTarget = projects.filter( - (project) => project.environmentId === environmentId, + const match = resolveEnvironmentProjectMatch( + projects.filter((project) => project.environmentId === environmentId), + selectedProject, ); - const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; - // Prefer the repository identity; projects without one (e.g. not yet - // indexed) fall back to workspace basename, then title, so switching - // computers still follows the same repo instead of resetting to - // whatever project is first on the target machine. - const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; - const match = - (repositoryKey !== null - ? projectsOnTarget.find( - (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, - ) - : undefined) ?? - (workspaceBasename !== null - ? projectsOnTarget.find( - (project) => project.workspaceRoot.split("/").at(-1) === workspaceBasename, - ) - : undefined) ?? - (selectedProject !== null - ? projectsOnTarget.find((project) => project.title === selectedProject.title) - : undefined); + if (match) { + carryDraftContentTo(match); + } setSelectedEnvironmentId(environmentId); setSelectedProjectKey(match ? scopedProjectKey(match.environmentId, match.id) : null); }, - [projects, selectedProject], + [projects, selectedProject, carryDraftContentTo], ); const setWorkspaceMode = useCallback( diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 2d52ed716d50..ca59a2b9dddc 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -6,15 +6,33 @@ import type { HomeProjectScope } from "../home/homeThreadList"; import { getProjectScopeSelectionTarget, resolveDraftProjectSelection, + resolveEnvironmentProjectMatch, } from "./new-task-project-selection"; -function makeProject(id: string, environmentId = "environment"): EnvironmentProject { +function makeProject( + id: string, + environmentId = "environment", + options: { + readonly title?: string; + readonly workspaceRoot?: string; + readonly repositoryKey?: string; + } = {}, +): EnvironmentProject { return { environmentId: EnvironmentId.make(environmentId), id: ProjectId.make(id), - title: id, - workspaceRoot: `/work/${id}`, - repositoryIdentity: null, + title: options.title ?? id, + workspaceRoot: options.workspaceRoot ?? `/work/${id}`, + repositoryIdentity: options.repositoryKey + ? { + canonicalKey: options.repositoryKey, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `https://${options.repositoryKey}.git`, + }, + } + : null, defaultModelSelection: null, scripts: [], createdAt: "2026-07-01T00:00:00.000Z", @@ -51,6 +69,55 @@ describe("getProjectScopeSelectionTarget", () => { }); }); +describe("resolveEnvironmentProjectMatch", () => { + it("follows the same repository onto the target machine", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [ + makeProject("other", "server", { repositoryKey: "github.com/t3tools/other" }), + makeProject("t3code-clone", "server", { repositoryKey: "github.com/t3tools/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[1]); + }); + + it("falls back to workspace basename, then title, for unindexed projects", () => { + const selected = makeProject("t3code", "mac", { workspaceRoot: "/Users/me/t3code" }); + const byBasename = [ + makeProject("other", "server"), + makeProject("srv", "server", { workspaceRoot: "/home/me/t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byBasename, selected)).toBe(byBasename[1]); + + const byTitle = [ + makeProject("other", "server"), + makeProject("srv", "server", { title: "t3code" }), + ]; + expect(resolveEnvironmentProjectMatch(byTitle, selected)).toBe(byTitle[1]); + }); + + it("does not treat a known different repository as a basename or title match", () => { + const selected = makeProject("t3code", "mac", { + repositoryKey: "github.com/t3tools/t3code", + workspaceRoot: "/Users/me/t3code", + }); + const fork = makeProject("fork", "server", { + repositoryKey: "github.com/someone/t3code", + title: "t3code", + workspaceRoot: "/home/me/t3code", + }); + const unindexed = makeProject("unindexed", "server", { workspaceRoot: "/srv/t3code" }); + expect(resolveEnvironmentProjectMatch([fork, unindexed], selected)).toBe(unindexed); + // Without any weaker match the fork is still the first-project fallback. + expect(resolveEnvironmentProjectMatch([fork], selected)).toBe(fork); + }); + + it("falls back to the first project on the target so the draft has a key to carry over to", () => { + const selected = makeProject("t3code", "mac", { repositoryKey: "github.com/t3tools/t3code" }); + const target = [makeProject("unrelated", "server"), makeProject("also-unrelated", "server")]; + expect(resolveEnvironmentProjectMatch(target, selected)).toBe(target[0]); + expect(resolveEnvironmentProjectMatch([], selected)).toBeNull(); + }); +}); + describe("resolveDraftProjectSelection", () => { it("preserves an explicit project selection", () => { const project = makeProject("t3code"); diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index 0528dc66687a..65dd9916f2f6 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -26,6 +26,53 @@ function getOnlySelectableProject( return onlyScope?.representative ?? null; } +/** + * Picks the project on a target environment that corresponds to the project + * currently selected in the new-task flow, so switching computers follows the + * same repo. Repository identity is preferred; projects without one (e.g. not + * yet indexed) fall back to workspace basename, then title. When nothing + * matches, the first project on the target stands in — the same fallback the + * render path applies when no key is selected — so the draft always has a + * concrete key to carry over to. + */ +export function resolveEnvironmentProjectMatch( + projectsOnTarget: ReadonlyArray, + selectedProject: EnvironmentProject | null, +): EnvironmentProject | null { + const repositoryKey = selectedProject?.repositoryIdentity?.canonicalKey ?? null; + // `|| null` (not `??`): a pending-task placeholder project can have an empty + // workspaceRoot, and an "" basename would match nothing meaningful. + const workspaceBasename = selectedProject?.workspaceRoot.split("/").at(-1) || null; + // The weaker signals only apply where identity is unknown on at least one + // side; two known, different repositories never match on a shared basename + // or title (mirrors the environment list filter in the new-task flow). + const isKnownMismatch = (project: EnvironmentProject) => { + const projectKey = project.repositoryIdentity?.canonicalKey ?? null; + return repositoryKey !== null && projectKey !== null && projectKey !== repositoryKey; + }; + return ( + (repositoryKey !== null + ? projectsOnTarget.find( + (project) => (project.repositoryIdentity?.canonicalKey ?? null) === repositoryKey, + ) + : undefined) ?? + (workspaceBasename !== null + ? projectsOnTarget.find( + (project) => + !isKnownMismatch(project) && + project.workspaceRoot.split("/").at(-1) === workspaceBasename, + ) + : undefined) ?? + (selectedProject !== null + ? projectsOnTarget.find( + (project) => !isKnownMismatch(project) && project.title === selectedProject.title, + ) + : undefined) ?? + projectsOnTarget[0] ?? + null + ); +} + export function resolveDraftProjectSelection( selectedProjectKey: string | null, projects: ReadonlyArray, diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index a5e85227e271..57c0ac91d147 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -145,6 +145,7 @@ vi.mock("../features/sharing/incoming-share-storage", () => ({ loadIncomingShareDrafts: incomingShareStorageMocks.load, })); +import type { DraftComposerAttachment } from "../lib/composerImages"; import { appAtomRegistry } from "./atom-registry"; import { threadOutboxManager } from "./thread-outbox"; import { @@ -1433,6 +1434,48 @@ describe("mobile composer drafts", () => { expect(copyComposerDraftContentState(drafts, sourceKey, targetKey)).toBe(drafts); }); + it("drops another environment's upload stamp when carrying attachments across machines", () => { + const sourceKey = "new-task:environment-1:project-1"; + const targetKey = "new-task:environment-2:project-2"; + const uploadedElsewhere: DraftComposerAttachment = { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + uploadedAttachmentId: "upload-1", + uploadEnvironmentId: EnvironmentId.make("environment-1"), + }; + const uploadedOnTarget: DraftComposerAttachment = { + ...uploadedElsewhere, + id: "image-2", + uploadedAttachmentId: "upload-2", + uploadEnvironmentId: EnvironmentId.make("environment-2"), + }; + + const next = copyComposerDraftContentState( + { [sourceKey]: { text: "Ship it", attachments: [uploadedElsewhere, uploadedOnTarget] } }, + sourceKey, + targetKey, + ); + + expect(next[targetKey]?.attachments).toEqual([ + { + id: "image-1", + type: "image", + name: "screen.png", + mimeType: "image/png", + sizeBytes: 1, + previewUri: "file:///drafts/screen.png", + fileUri: "file:///drafts/screen.png", + }, + uploadedOnTarget, + ]); + expect(next[sourceKey]?.attachments).toEqual([uploadedElsewhere, uploadedOnTarget]); + }); + it("merges shared content into a project draft without duplicating retries", () => { const draftKey = "new-task:environment-1:project-1"; const sharedAttachment = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 6b463c2d2624..bf25866b14ce 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1049,17 +1049,35 @@ export function copyComposerDraftContentState( if (!sourceHasContent || targetHasContent) { return current; } + // Pending uploads live on one server. Crossing environments keeps the local + // bytes (the upload worker re-sends them to the new key's environment) but + // drops the old stamp, so it cannot pin the source environment's pending + // upload alive from the copy. + const targetEnvironmentId = composerDraftEnvironmentId(targetDraftKey, []); + const attachments = source.attachments.map((attachment) => + attachment.uploadEnvironmentId !== undefined && + attachment.uploadEnvironmentId !== targetEnvironmentId + ? stripAttachmentUploadReference(attachment) + : attachment, + ); return { ...current, [targetDraftKey]: { ...target, text: source.text, - attachments: source.attachments, + attachments, ...(source.importedShareIds ? { importedShareIds: source.importedShareIds } : {}), }, }; } +function stripAttachmentUploadReference( + attachment: DraftComposerAttachment, +): DraftComposerAttachment { + const { uploadedAttachmentId: _id, uploadEnvironmentId: _environmentId, ...rest } = attachment; + return rest; +} + export async function copyComposerDraftContentIfEmpty( sourceDraftKey: string, targetDraftKey: string, From 2c3353578098a9e55e203a72217abe993f97987e Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 6 Sep 2026 05:52:07 +0200 Subject: [PATCH 168/320] fix(web): keep timestamp tooltip dates in English (#10256) --- apps/web/src/timestampFormat.test.ts | 19 +++++++++++++++++++ apps/web/src/timestampFormat.ts | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/apps/web/src/timestampFormat.test.ts b/apps/web/src/timestampFormat.test.ts index ca73095f7fd4..8c6287010d8f 100644 --- a/apps/web/src/timestampFormat.test.ts +++ b/apps/web/src/timestampFormat.test.ts @@ -52,6 +52,25 @@ describe("formatShortTimestamp", () => { }); }); +describe("formatChatTimestampTooltip", () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.resetModules(); + }); + + it.each(["de-DE", "it-IT"])("keeps the English date label in a %s runtime", async (locale) => { + const DateTimeFormat = Intl.DateTimeFormat; + vi.spyOn(Intl, "DateTimeFormat").mockImplementation(function (locales, options) { + return new DateTimeFormat(locales ?? locale, options); + }); + vi.resetModules(); + const { formatChatTimestampTooltip: format } = await import("./timestampFormat"); + const date = new Date(2026, 5, 4, 14, 4).toISOString(); + + expect(format(date, "24-hour")).toBe("14:04, 4th June 2026"); + }); +}); + describe("formatExpiresInLabel", () => { beforeEach(() => { vi.useFakeTimers(); diff --git a/apps/web/src/timestampFormat.ts b/apps/web/src/timestampFormat.ts index 0f87204efde4..9dd463bb50fa 100644 --- a/apps/web/src/timestampFormat.ts +++ b/apps/web/src/timestampFormat.ts @@ -81,7 +81,7 @@ export function parseTimestampDate(isoDate: string): Date | null { // Deliberately not the host locale: the tooltip's ordinal suffix and // day-before-month order below are English, so a localized month alone would // read "4th Juni 2026". Localizing the whole label is a separate change. -const monthNameFormatter = new Intl.DateTimeFormat(undefined, { month: "long" }); +const monthNameFormatter = new Intl.DateTimeFormat("en-US", { month: "long" }); function ordinalSuffix(day: number): string { const lastTwo = day % 100; From 3da9399b1ac4e015f8db29c49718a74c57e62e83 Mon Sep 17 00:00:00 2001 From: oliver <97427849+flamboh@users.noreply.github.com> Date: Sat, 5 Sep 2026 20:53:03 -0700 Subject: [PATCH 169/320] fix(web): let authorized clients scrolling reach settings (#10080) --- apps/web/src/components/settings/ConnectionsSettings.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 5a6f3ebd70b0..da46e067d47a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3222,6 +3222,7 @@ export function ConnectionsSettings() { > From add8c3a55ac8a7d520dc2ea11a8fa05bc3cee361 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:54:18 +0300 Subject: [PATCH 170/320] fix(web): remember usage page selection (#10189) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- .../src/components/usage/UsagePage.test.tsx | 39 ++++++----- apps/web/src/components/usage/UsagePage.tsx | 33 +++++++-- .../usage/usagePagePreferences.test.ts | 70 +++++++++++++++++++ .../components/usage/usagePagePreferences.ts | 32 +++++++++ 4 files changed, 151 insertions(+), 23 deletions(-) create mode 100644 apps/web/src/components/usage/usagePagePreferences.test.ts create mode 100644 apps/web/src/components/usage/usagePagePreferences.ts diff --git a/apps/web/src/components/usage/UsagePage.test.tsx b/apps/web/src/components/usage/UsagePage.test.tsx index 944987388b06..e41843e6d9cc 100644 --- a/apps/web/src/components/usage/UsagePage.test.tsx +++ b/apps/web/src/components/usage/UsagePage.test.tsx @@ -5,7 +5,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; const testState = vi.hoisted(() => ({ useUsage: vi.fn(), - metric: "cost" as "cost" | "tokens", + metric: "cost" as "cost" | "tokens" | "limits", breakdown: "time" as "model" | "time", })); @@ -14,23 +14,25 @@ vi.mock("react", async (importOriginal) => { return { ...actual, useState: vi.fn((initial: unknown) => [ - typeof initial === "function" - ? { - days: 1, - window: { - sinceDay: "2026-08-10", - untilDay: "2026-08-11", - timeZone: "UTC", - resolution: "hour", - sinceTime: "2026-08-10T12:37:00.000Z", - untilTime: "2026-08-11T12:37:00.000Z", - }, - } - : initial === "cost" - ? testState.metric - : initial === "model" - ? testState.breakdown - : initial, + initial === readUsagePagePreferences + ? { metric: testState.metric, windowDays: 30 } + : typeof initial === "function" + ? { + days: 1, + window: { + sinceDay: "2026-08-10", + untilDay: "2026-08-11", + timeZone: "UTC", + resolution: "hour", + sinceTime: "2026-08-10T12:37:00.000Z", + untilTime: "2026-08-11T12:37:00.000Z", + }, + } + : initial === "cost" + ? testState.metric + : initial === "model" + ? testState.breakdown + : initial, vi.fn(), ]), }; @@ -70,6 +72,7 @@ vi.mock("./usageProviders", async (importOriginal) => { }); import { UsagePage } from "./UsagePage"; +import { readUsagePagePreferences } from "./usagePagePreferences"; const providerTotals = (codex: number, claude: number) => new Map([ diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index f0dcec49ad90..deb05f266b98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -62,6 +62,11 @@ import { UsageLimitsSection } from "./UsageLimits"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; +import { + readUsagePagePreferences, + saveUsagePagePreferences, + type UsagePagePreferences, +} from "./usagePagePreferences"; type UsageMetric = UsageChartMetric | "limits"; const METRIC_OPTIONS = [ @@ -81,12 +86,21 @@ const WINDOW_OPTIONS = [ { days: 90, label: "90 days" }, ] as const; +function isUsageWindowDays(value: number): value is UsagePagePreferences["windowDays"] { + return WINDOW_OPTIONS.some((option) => option.days === value); +} + export function UsagePage() { + const [preferences, setPreferences] = useState(readUsagePagePreferences); const [windowSelection, setWindowSelection] = useState(() => ({ - days: 30, - window: makeWindow(30), + days: preferences.windowDays, + window: makeWindow( + preferences.windowDays, + undefined, + preferences.windowDays === 1 ? "hour" : "day", + ), })); - const [metric, setMetric] = useState("cost"); + const metric = preferences.metric; const showingLimits = metric === "limits"; const [isRefreshing, setIsRefreshing] = useState(false); const refreshingRef = useRef(false); @@ -134,11 +148,20 @@ export function UsagePage() { const timeValueColumnWidth = `${60 / (activeProviders.length + 2)}%`; const selectWindow = (days: number) => { + if (!isUsageWindowDays(days)) return; + const nextPreferences = { metric, windowDays: days }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); setWindowSelection({ days, window: makeWindow(days, undefined, days === 1 ? "hour" : "day"), }); }; + const selectMetric = (nextMetric: UsageMetric) => { + const nextPreferences = { metric: nextMetric, windowDays }; + setPreferences(nextPreferences); + saveUsagePagePreferences(nextPreferences); + }; const refreshWindow = () => { if (refreshingRef.current) return; @@ -210,7 +233,7 @@ export function UsagePage() { value={[metric]} onValueChange={(next) => { const value = next[0]; - if (isUsageMetric(value)) setMetric(value); + if (isUsageMetric(value)) selectMetric(value); }} > {METRIC_OPTIONS.map((option) => ( @@ -252,7 +275,7 @@ export function UsagePage() {

Node.js required. Modem optional.

System requirements
  • A modern computer
  • A supported coding agent
  • A dream, ideally a small one

Does not actually run on Windows 95.

+ + +
+

Questions

+
Wait. Is this a real product?

Yes. T3 Code is a real, free, open-source app used by {MARKETING_STATS.users} developers. The packaging is a joke. The app is not. Visit the regular website.

+
Does this replace my Claude or Codex subscription?

No. T3 Code connects to the coding agents you already use. Keep your provider accounts and subscriptions. T3 Code gives you one app to work with them.

+
Will it run on Windows 95?

Absolutely not. We brought back the look, not the driver problems. Get a build for a current version of Windows, macOS, or Linux.

+
Where do I mail my check?

Please do not mail us a check for zero dollars. Just download the app. The entire accounts department is a download button.

+
+ + + + +
Done. Internet
+
+ + +
+ +
+
Start
+ + +
4:04 PM
+
+ +
T3 Code '95

Get T3 Code
+ + + + diff --git a/apps/marketing/src/styles/retro.css b/apps/marketing/src/styles/retro.css new file mode 100644 index 000000000000..df166eb71ded --- /dev/null +++ b/apps/marketing/src/styles/retro.css @@ -0,0 +1,1244 @@ +/* This page has its own document so the retro styles do not affect other pages. */ +:root { + color-scheme: dark; + font-family: Tahoma, Verdana, Arial, sans-serif; + color: #fff; + background: #000; + --silver: #c0c0c0; + --yellow: #eaff00; + --pink: #ff79bd; + --navy: #000080; +} + +* { + box-sizing: border-box; +} +body { + margin: 0; + min-width: 320px; + height: 100dvh; + overflow: hidden; +} +button, +input { + font: inherit; +} +button, +a, +summary { + -webkit-tap-highlight-color: transparent; +} +button, +summary { + cursor: pointer; +} +button { + color: inherit; +} +a { + color: inherit; +} +button { + border-radius: 0; +} +svg { + flex-shrink: 0; +} +[hidden] { + display: none !important; +} +:focus-visible { + outline: 2px dashed var(--pink); + outline-offset: 4px; +} +section { + scroll-margin-top: 24px; +} +.skip-link { + position: fixed; + top: -80px; + left: 12px; + z-index: 100; +} +.skip-link:focus { + top: 12px; +} +.raised { + border: 2px solid; + border-color: #fff #333 #333 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; +} +.sunken { + border: 2px solid; + border-color: #808080 #fff #fff #808080; + box-shadow: inset 1px 1px #000; +} +.retro-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 9px; + border: 2px solid; + border-color: #fff #000 #000 #fff; + box-shadow: + inset -1px -1px #808080, + inset 1px 1px #dfdfdf; + padding: 8px 16px; + background: var(--silver); + color: #000; + text-decoration: none; + font-size: 12px; + font-weight: 700; +} +.retro-button:active { + border-color: #000 #fff #fff #000; + box-shadow: inset 1px 1px #808080; +} +.retro-button:hover { + background: #d7d7d7; +} +.desktop { + position: fixed; + inset: 0 0 42px; + z-index: 1; + max-width: 1280px; + margin: 0 auto; + padding: 28px 30px 40px 112px; + pointer-events: none; +} +.desktop-icons { + position: absolute; + top: 37px; + left: max(10px, calc((100vw - 1280px) / 2 + 10px)); + width: 83px; + display: grid; + gap: 29px; +} +.desktop-icon { + display: flex; + flex-direction: column; + align-items: center; + gap: 7px; + border: 0; + background: none; + color: #fff; + text-align: center; + text-decoration: none; + font-size: 11px; + line-height: 1.4; + padding: 3px 0; +} +.desktop-icon:hover span, +.desktop-icon:focus-visible span { + background: var(--navy); +} +.browser-window { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; + padding: 3px; + background: var(--silver); + pointer-events: auto; + transform: translate(var(--window-x, 0px), var(--window-y, 0px)); +} +.browser-window > :not(main) { + flex-shrink: 0; +} +#window-title { + cursor: grab; + touch-action: none; + user-select: none; +} +#window-title.dragging { + cursor: grabbing; +} +#window-title:focus-visible { + outline-offset: -2px; +} +.window-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + min-height: 27px; + padding: 3px 4px 3px 6px; + background: linear-gradient(90deg, #000080, #2253a4); + color: #fff; + font-size: 12px; + font-weight: 700; +} +.window-name { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} +.window-name > span { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} +.window-controls { + display: flex; + gap: 3px; +} +.window-control { + width: 20px; + height: 20px; + min-width: 20px; + padding: 0; + font: + 700 18px Arial, + sans-serif; +} +.window-control:first-child { + font-size: 17px; +} +.maximize-icon { + width: 10px; + height: 10px; + border: 1px solid #000; + border-top-width: 3px; +} +.browser-menu { + display: flex; + align-items: center; + gap: 2px; + padding: 3px 4px; + color: #000; +} +.browser-menu > a, +.browser-menu > button { + padding: 5px 8px; + border: 0; + background: none; + text-decoration: none; + font-size: 11px; +} +.browser-menu > a:hover, +.browser-menu > button:hover { + color: #fff; + background: var(--navy); +} +.address-bar { + display: flex; + align-items: center; + gap: 9px; + padding: 4px 7px 9px; + color: #000; + font-size: 11px; +} +.address-field { + display: flex; + align-items: center; + gap: 7px; + flex: 1; + padding: 4px 6px; + background: #fff; + min-width: 0; +} +.address-field > span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.address-go { + align-self: stretch; + padding: 2px 9px; + font-weight: 400; +} +main { + flex: 1; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + border: 2px solid; + border-color: #555 #fff #fff #555; + background: #000; +} +main::-webkit-scrollbar { + width: 16px; + height: 16px; +} +main::-webkit-scrollbar-track, +main::-webkit-scrollbar-corner { + background: #dfdfdf; +} +main::-webkit-scrollbar-thumb { + border: 2px solid; + border-color: #fff #333 #333 #fff; + background: var(--silver); + box-shadow: inset -1px -1px #808080; +} +.hero { + display: grid; + grid-template-columns: 1.1fr 1fr; + align-items: center; + padding: 16px 35px 20px; + gap: 8px; +} +.hero h1 { + font-size: clamp(36px, 3.4vw, 48px); + letter-spacing: -2px; +} +.hero .hero-explanation { + margin: 14px 0 0; +} +.edition-packages { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; + align-items: end; +} +.hero-package { + display: block; + width: min(100%, 170px); + margin: 0 auto; + text-decoration: none; +} +.hero-package > img { + display: block; + width: 100%; + height: auto; +} +.hero-package > .edition-download { + display: flex; + justify-content: center; + padding: 6px 8px; + margin-top: 4px; + font-size: 11px; + background: var(--yellow); +} +.nightly-package > .edition-download { + background: #c9bdff; +} +h1 { + margin: 0; + font: + 900 clamp(40px, 4.7vw, 62px)/0.99 Arial, + Helvetica, + sans-serif; + letter-spacing: -3.3px; +} +h1 > span { + color: var(--yellow); +} +.hero-explanation { + max-width: 360px; + margin: 0 0 23px; + font-size: 12px; + line-height: 1.7; + color: #c0c0c0; +} +.primary-cta { + padding: 13px 17px; + gap: 12px; + background: var(--yellow); + border-color: #ffffd1 #737c00 #737c00 #ffffd1; + box-shadow: + inset -1px -1px #a3b000, + inset 1px 1px #ffffbd; + font: + 900 13px Arial, + sans-serif; + letter-spacing: 0.3px; +} +.primary-cta > span:last-child { + font-size: 22px; + margin-left: 8px; +} +.primary-cta:hover { + background: #f2ff73; +} +.platform-line { + display: flex; + margin-top: 16px; + flex-wrap: wrap; + align-items: center; + gap: 11px; + font-size: 10px; +} +.platform-line > span { + font: + 8px "Courier New", + monospace; + color: #ababab; + letter-spacing: 0.5px; +} +.platform-line > strong { + font-weight: 400; +} +.platform-line > b { + color: #656565; +} +.box-95 { + position: absolute; + right: 9px; + bottom: -6px; + font: + italic 900 75px Arial, + sans-serif; + color: var(--yellow); + letter-spacing: -6px; +} +.agents-section { + padding: 14px 35px 24px; + border-top: 1px solid #363636; + border-bottom: 1px solid #363636; +} +.agent-list { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 26px 20px; + margin: 10px 0 24px; +} +.agent { + min-width: 0; + margin: 0; + text-align: center; +} +.agent-box { + display: block; + width: 100%; + max-width: 200px; + height: auto; + margin: 0 auto; + object-fit: contain; +} +.agents-section > .section-heading { + margin-bottom: 0; +} +.agent > figcaption { + padding: 12px 4px 0; + border-top: 3px ridge #777; +} +.agent h3 { + margin: 0 0 6px; + font-size: 14px; +} +.agent p { + max-width: 27ch; + margin: 0 auto; + color: var(--yellow); + font: + 11px/1.5 "Courier New", + monospace; +} +.agents-note { + margin: 0; + color: #aaa; + font: + 9px/1.5 "Courier New", + monospace; +} +.features-section { + padding: 33px 35px 38px; +} +.section-heading { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 20px; + margin-bottom: 21px; +} +.section-heading h2 { + font: + 900 20px/1.2 Arial, + sans-serif; + letter-spacing: -0.6px; + margin: 0; +} +.section-heading h2 > span { + color: var(--yellow); +} +.section-heading > span { + font: + 8px/1.5 "Courier New", + monospace; + color: #aaa; + text-align: right; +} +.feature-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 15px; +} +.feature-window { + min-width: 0; + padding: 3px; + background: var(--silver); +} +.feature-titlebar { + display: flex; + justify-content: space-between; + gap: 5px; + padding: 5px 6px; + background: #393939; + font: + 9px "Courier New", + monospace; +} +.feature-body { + background: #0b0b0b; + padding: 18px 15px 17px; + display: flex; + flex-direction: column; + align-items: flex-start; + height: calc(100% - 21px); +} +.feature-body h3 { + font: + 700 18px/1.15 Arial, + sans-serif; + margin: 16px 0 12px; + letter-spacing: -0.3px; +} +.feature-body p { + margin: 0 0 22px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; +} +.feature-tag { + display: block; + margin-top: auto; + color: var(--yellow); + font: + 700 8px/1.6 "Courier New", + monospace; + letter-spacing: 0.3px; + text-decoration: none; +} +a.feature-tag { + text-decoration: underline; + text-underline-offset: 3px; +} +.order-section { + position: relative; + display: grid; + grid-template-columns: 1.35fr 1fr; + gap: 45px; + padding: 32px 35px; + border-top: 1px solid #546124; + border-bottom: 1px solid #546124; + background: #121707; + align-items: center; +} +.order-pitch h2 { + font: + 900 30px/1.1 Arial, + sans-serif; + letter-spacing: -1px; + margin: 15px 0 12px; +} +.order-pitch h2 > span { + color: var(--yellow); +} +.order-pitch > p { + font-size: 11px; + line-height: 1.6; + margin: 0 0 23px; +} +.order-pitch .offer-note { + color: #b9bea9; + font: + 9px/1.7 "Courier New", + monospace; + margin: 13px 0 0; +} +.run-window { + padding: 3px; + background: var(--silver); + color: #000; +} +.run-window .window-title { + min-height: 23px; + font-size: 11px; +} +.run-body { + padding: 14px 13px; + font-size: 11px; +} +.run-body > p:first-child { + margin: 0 0 14px; + font-weight: 700; +} +.run-body label { + font-size: 10px; +} +.command-row { + display: flex; + gap: 7px; + margin-top: 7px; +} +.command-row input { + width: 0; + min-width: 0; + flex: 1; + border-radius: 0; + padding: 7px 8px; + background: #fff; + color: #000; + font: + 700 15px "Courier New", + monospace; +} +.command-row button { + padding: 5px 12px; +} +.command-feedback { + min-height: 27px; + margin: 8px 0 10px; + font: + 9px/1.5 "Courier New", + monospace; +} +.run-divider { + border-top: 1px solid #808080; + border-bottom: 1px solid #fff; + margin: 0 0 13px; +} +.run-body > b { + font-size: 10px; +} +.run-body ul { + padding-left: 17px; + margin: 7px 0 10px; + font-size: 10px; + line-height: 1.8; +} +.requirements-note { + font: + 8px "Courier New", + monospace; + margin-bottom: 0; +} +.faq-section { + padding: 34px 35px; +} +.faq-section > h2 { + margin: 0 0 20px; + color: var(--pink); + font: + 700 11px "Courier New", + monospace; +} +.faq-section > details { + border-top: 1px dotted #626262; +} +.faq-section > details:last-child { + border-bottom: 1px dotted #626262; +} +.faq-section summary { + padding: 13px 2px; + font-size: 12px; +} +.faq-section summary::marker { + color: var(--yellow); +} +.faq-section details p { + margin: 0; + padding: 0 20px 16px; + color: #c0c0c0; + font-size: 11px; + line-height: 1.7; + max-width: 740px; +} +.faq-section a { + color: var(--yellow); + text-underline-offset: 3px; +} +.site-footer { + margin: 0 35px; + padding: 25px 0 27px; + text-align: center; + border-top: 1px solid #363636; +} +.visitor-counter { + display: flex; + align-items: center; + justify-content: center; + flex-wrap: wrap; + gap: 12px; + font: + 9px "Courier New", + monospace; + color: #c0c0c0; +} +.counter-digits { + display: inline-flex; + gap: 2px; + padding: 3px; + border: 2px inset #666; + background: #141414; +} +.counter-digits > span { + display: block; + padding: 2px 4px; + background: #252b1a; + color: var(--yellow); + font: + 700 15px "Courier New", + monospace; +} +.site-footer > p { + color: #b0b0b0; + font: + 9px/1.6 "Courier New", + monospace; + margin: 0 0 10px; +} +.site-footer nav { + display: flex; + flex-wrap: wrap; + gap: 18px; + justify-content: center; + font: + 9px "Courier New", + monospace; +} +.site-footer nav a { + color: var(--pink); + text-underline-offset: 3px; +} +.browser-status { + display: flex; + align-items: stretch; + gap: 4px; + height: 24px; + padding-top: 4px; + color: #000; + font-size: 10px; +} +.browser-status > span { + display: flex; + align-items: center; + gap: 4px; + padding: 2px 4px; +} +.browser-status > span:first-child { + flex: 1; +} +.browser-status > span:nth-child(2) { + min-width: 120px; +} +.resize-grip { + width: 13px; + background: repeating-linear-gradient(135deg, transparent 0 2px, #808080 2px 3px, #fff 3px 4px); + clip-path: polygon(100% 0, 100% 100%, 0 100%); +} +.taskbar { + position: fixed; + z-index: 10; + left: 0; + right: 0; + bottom: 0; + min-height: 40px; + padding: 3px 5px; + background: var(--silver); + color: #000; + display: flex; + align-items: center; + gap: 8px; +} +.start-menu { + position: relative; +} +.start-button { + gap: 7px; + padding: 4px 8px; + min-height: 30px; + font-size: 14px; + list-style: none; +} +.start-button::-webkit-details-marker { + display: none; +} +.start-mark { + display: grid; + grid-template-columns: 8px 8px; + gap: 2px; + transform: skewY(-8deg); +} +.start-mark i { + width: 8px; + height: 8px; + background: #f3433c; +} +.start-mark i:nth-child(2) { + background: #75b53c; +} +.start-mark i:nth-child(3) { + background: #347be3; +} +.start-mark i:nth-child(4) { + background: #ffe348; +} +.start-panel { + position: absolute; + bottom: calc(100% + 5px); + left: -1px; + display: flex; + width: 253px; + padding: 3px; + background: var(--silver); +} +.start-brand { + writing-mode: vertical-rl; + transform: rotate(180deg); + background: #808080; + color: #dedede; + padding: 12px 8px; + font: + 900 18px Arial, + sans-serif; + white-space: nowrap; +} +.start-brand b { + color: #fff; +} +.start-panel > div:last-child { + flex: 1; +} +.start-panel a, +.start-panel button { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 10px; + width: 100%; + background: none; + border: 0; + color: #000; + text-decoration: none; + font-size: 11px; + text-align: left; +} +.start-panel a:hover, +.start-panel button:hover { + background: var(--navy); + color: #fff; +} +.taskbar-divider { + align-self: stretch; + border-left: 1px solid #808080; + border-right: 1px solid #fff; +} +.task-button { + display: flex; + align-items: center; + gap: 8px; + background: #d7d7d7; + color: #000; + padding: 3px 8px; + min-height: 29px; + min-width: 170px; + font-size: 11px; + font-weight: 700; + text-align: left; +} +.taskbar-clock { + margin-left: auto; + display: flex; + align-items: center; + justify-content: center; + gap: 9px; + padding: 4px 10px; + min-height: 29px; + font-size: 11px; + white-space: nowrap; +} +.taskbar-clock > span:first-child { + font-size: 16px; +} +.maximized .desktop { + max-width: none; + padding: 0; +} +.maximized .browser-window { + transform: none; +} +.maximized #window-title { + cursor: default; +} +.minimized-message { + height: 100%; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 15px; + text-align: center; +} +.minimized-message h1 { + font: + 700 25px Arial, + sans-serif; + letter-spacing: -0.5px; +} +.minimized-message p { + font: + 12px "Courier New", + monospace; + margin: 0 0 10px; +} +.retro-dialog { + width: min(440px, calc(100vw - 32px)); + padding: 3px; + background: var(--silver); + color: #000; +} +.retro-dialog::backdrop { + background: #000b; +} +.dialog-content { + display: flex; + align-items: center; + gap: 20px; + padding: 22px 20px 13px; +} +.dialog-content > p { + font-size: 12px; + line-height: 1.7; + margin: 0; +} +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 8px 16px 17px; +} + +@media (min-width: 1450px) { + .maximized .hero { + grid-template-columns: 1fr 1fr; + padding-left: 65px; + padding-right: 65px; + } + .maximized h1 { + font-size: 76px; + } +} + +@media (max-width: 1100px) { + .desktop { + padding-left: 98px; + padding-right: 15px; + } + .hero { + padding-left: 25px; + padding-right: 25px; + } + h1 { + font-size: 47px; + letter-spacing: -2.6px; + } + .order-section { + gap: 23px; + } + .order-pitch h2 { + font-size: 27px; + } + .feature-grid { + gap: 10px; + } + .feature-body { + padding: 15px 11px; + } +} + +@media (max-width: 820px) { + .desktop { + padding: 14px 12px; + } + .desktop-icons { + display: none; + } + .hero { + grid-template-columns: 1.1fr 0.9fr; + gap: 0; + padding-top: 29px; + } + h1 { + font-size: 44px; + } + .hero-explanation { + max-width: 295px; + } + .primary-cta { + font-size: 11px; + gap: 7px; + padding: 12px; + } + .primary-cta > span:last-child { + margin-left: 3px; + } + .section-heading { + display: block; + } + .section-heading > span { + display: block; + text-align: left; + margin-top: 8px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 25px; + padding-right: 25px; + } + .site-footer { + margin-left: 25px; + margin-right: 25px; + } + .feature-body h3 { + font-size: 16px; + } + .taskbar-clock { + margin-left: auto; + } +} + +@media (max-width: 620px) { + .browser-menu { + flex-wrap: wrap; + } + .hero-package { + width: min(100%, 160px); + } + .edition-packages { + width: 100%; + margin-top: 12px; + } + .desktop { + padding: 9px 7px; + } + .window-name { + font-size: 10px; + } + .window-controls { + gap: 2px; + } + .browser-menu { + gap: 0; + } + .browser-menu > a, + .browser-menu > button { + padding: 6px 7px; + font-size: 10px; + } + .address-bar { + padding: 3px 4px 7px; + gap: 6px; + font-size: 10px; + } + .address-go { + font-size: 10px; + } + .hero { + display: flex; + flex-direction: column; + align-items: stretch; + padding: 29px 20px 15px; + } + h1 { + font-size: clamp(41px, 10.5vw, 63px); + letter-spacing: -2.5px; + } + .hero-explanation { + max-width: 420px; + font-size: 11px; + } + .primary-cta { + font-size: 12px; + padding: 12px 15px; + gap: 10px; + } + .platform-line { + font-size: 9px; + gap: 9px; + } + .agents-section, + .features-section, + .order-section, + .faq-section { + padding: 25px 20px; + } + .agent-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 22px 12px; + margin-top: 20px; + } + .agent h3 { + font-size: 13px; + } + .agent p { + font-size: 10px; + } + .agents-note { + font-size: 8px; + } + .section-heading h2 { + font-size: 20px; + } + .section-heading h2 > span { + display: block; + } + .feature-grid { + grid-template-columns: 1fr; + gap: 17px; + } + .feature-body { + padding: 17px; + height: auto; + } + .feature-body h3 { + font-size: 21px; + margin-top: 13px; + } + .feature-body p { + font-size: 12px; + margin-bottom: 18px; + } + .feature-tag { + font-size: 9px; + } + .feature-titlebar { + font-size: 10px; + } + .order-section { + grid-template-columns: 1fr; + gap: 25px; + } + .order-pitch h2 { + font-size: 29px; + } + .order-pitch > p { + font-size: 11px; + } + .run-body { + padding: 16px; + } + .command-feedback { + min-height: 15px; + } + .faq-section h2 { + font-size: 10px; + line-height: 1.5; + } + .faq-section summary { + font-size: 11px; + line-height: 1.5; + } + .site-footer { + margin-left: 20px; + margin-right: 20px; + } + .visitor-counter { + font-size: 8px; + } + .site-footer > p { + font-size: 8px; + } + .site-footer nav { + font-size: 8px; + gap: 15px; + } + .browser-status { + font-size: 8px; + height: 26px; + } + .browser-status > span:nth-child(2) { + min-width: 67px; + } + .browser-status > span:first-child { + white-space: nowrap; + overflow: hidden; + } + .resize-grip { + display: none !important; + } + .taskbar { + gap: 6px; + } + .task-button { + min-width: 0; + flex: 1; + max-width: 170px; + } + .taskbar-clock { + padding: 4px 7px; + gap: 5px; + font-size: 10px; + } + .dialog-content { + padding: 19px 13px 10px; + gap: 12px; + } +} + +@media (max-width: 620px) { + .hero { + padding-top: 14px; + padding-bottom: 14px; + } + .hero h1 { + font-size: 30px; + } + .hero-package { + max-width: 120px; + } + .hero .platform-line { + display: none; + } + .agents-section { + padding-top: 14px; + } + .agents-section .agent-list { + margin-top: 8px; + } +} + +@media (min-width: 821px) and (max-height: 820px) { + .hero { + padding-top: 10px; + padding-bottom: 12px; + } + .hero h1 { + font-size: 40px; + } + .hero-package { + max-width: 140px; + } + .agent-box { + max-width: 180px; + } + .agents-section { + padding-top: 8px; + } +} + +@media (max-width: 360px) { + .hero-package { + max-width: 110px; + } + .hero, + .agents-section, + .features-section, + .order-section, + .faq-section { + padding-left: 14px; + padding-right: 14px; + } + .primary-cta { + font-size: 10px; + } + .taskbar-clock > span:first-child { + display: none; + } +} From fdcc491e0b36245d4b4c74d1fd338d874f4ec84f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:28 -0700 Subject: [PATCH 175/320] refactor(web): remove unused runtime wrappers and exports (#10225) --- apps/web/src/assets/assetUrls.ts | 8 -- apps/web/src/assets/projectFaviconCache.ts | 2 +- apps/web/src/cloud/connectCliAuth.ts | 2 +- apps/web/src/cloud/managedRelayLayer.ts | 2 +- apps/web/src/cloud/managedRelayState.ts | 9 +- apps/web/src/cloud/primaryCloudLinkState.ts | 2 +- apps/web/src/connection/desktopLocal.ts | 2 +- apps/web/src/environments/primary/auth.ts | 91 +------------------ apps/web/src/environments/primary/context.ts | 2 +- apps/web/src/environments/primary/index.ts | 15 +-- .../src/environments/primary/sessionState.ts | 2 +- apps/web/src/lib/runtime.ts | 2 - apps/web/src/observability/clientTracing.ts | 9 -- apps/web/src/rpc/requestLatencyState.ts | 2 +- apps/web/src/rpc/transportError.ts | 5 +- apps/web/src/state/entities.ts | 2 +- apps/web/src/state/environments.ts | 5 - apps/web/src/state/queries.ts | 31 ------- apps/web/src/state/server.ts | 2 +- apps/web/src/state/shell.ts | 5 - apps/web/src/state/threads.ts | 2 +- 21 files changed, 15 insertions(+), 187 deletions(-) diff --git a/apps/web/src/assets/assetUrls.ts b/apps/web/src/assets/assetUrls.ts index 84ff979e4e89..5a9738c9fbfa 100644 --- a/apps/web/src/assets/assetUrls.ts +++ b/apps/web/src/assets/assetUrls.ts @@ -32,14 +32,6 @@ export function useAssetUrlState( ); } -export function useAssetUrl( - environmentId: EnvironmentId | null, - resource: AssetResource | null, -): string | null { - const result = useAssetUrlState(environmentId, resource); - return result._tag === "Success" ? result.url : null; -} - export function useAssetUrlRefresh( environmentId: EnvironmentId | null, resource: AssetResource | null, diff --git a/apps/web/src/assets/projectFaviconCache.ts b/apps/web/src/assets/projectFaviconCache.ts index e6fcc817b969..b44e919af4b3 100644 --- a/apps/web/src/assets/projectFaviconCache.ts +++ b/apps/web/src/assets/projectFaviconCache.ts @@ -46,7 +46,7 @@ async function withStore( } /** Rasterizes a bitmap that is too large to inline, retrying at half size. */ -export async function downscaleProjectFavicon( +async function downscaleProjectFavicon( image: { readonly mimeType: string; readonly bytes: Uint8Array }, signal: AbortSignal, ) { diff --git a/apps/web/src/cloud/connectCliAuth.ts b/apps/web/src/cloud/connectCliAuth.ts index 815715da2499..0bc65080cf8c 100644 --- a/apps/web/src/cloud/connectCliAuth.ts +++ b/apps/web/src/cloud/connectCliAuth.ts @@ -12,7 +12,7 @@ import { hasCloudPublicConfig, resolveCloudPublicConfig, trimNonEmpty } from "./ const CONNECT_CLI_AUTH_STATE_STORAGE_KEY = "t3code-connect-cli-auth-state"; -export function resolveConnectCliOAuthClientId(): string | null { +function resolveConnectCliOAuthClientId(): string | null { return trimNonEmpty(import.meta.env.VITE_CLERK_CLI_OAUTH_CLIENT_ID as string | undefined); } diff --git a/apps/web/src/cloud/managedRelayLayer.ts b/apps/web/src/cloud/managedRelayLayer.ts index 52f9b6496c95..b5ce11e842f5 100644 --- a/apps/web/src/cloud/managedRelayLayer.ts +++ b/apps/web/src/cloud/managedRelayLayer.ts @@ -13,7 +13,7 @@ import { type BrowserDpopKey, } from "./dpop"; -export const relayDpopSignerLayer = Layer.effect( +const relayDpopSignerLayer = Layer.effect( ManagedRelay.ManagedRelayDpopSigner, Effect.gen(function* () { const crypto = yield* Crypto.Crypto; diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index 9a56bde88514..c8f33d1d9d3d 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -33,7 +33,7 @@ const managedRelayAtomRuntime = Atom.runtime( ), ); -export const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); +const managedRelayQueryManager = createManagedRelayQueryManager(managedRelayAtomRuntime); const managedRelayMutationScheduler = createAtomCommandScheduler(); @@ -114,10 +114,3 @@ export function useManagedRelayDevices() { refresh, }; } - -export function refreshManagedRelayEnvironments(): void { - const session = appAtomRegistry.get(managedRelaySessionAtom); - if (session) { - managedRelayQueryManager.refreshEnvironments(appAtomRegistry, session.accountId); - } -} diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 34fdacd214af..c5871fa65d66 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -42,7 +42,7 @@ function targetKey(target: CloudLinkTarget): string { return JSON.stringify(target); } -export function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { +function refreshPrimaryCloudLinkState(target: CloudLinkTarget | null): void { if (target) { appAtomRegistry.refresh(primaryCloudLinkStateAtom(targetKey(target))); } diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index c9d8b938771b..d27d20e5b317 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -17,7 +17,7 @@ import { * via {@link isDesktopLocalConnectionTarget}, so the convention can never drift * between the two. */ -export const DESKTOP_LOCAL_CONNECTION_ID_PREFIX = "local:"; +const DESKTOP_LOCAL_CONNECTION_ID_PREFIX = "local:"; export function desktopLocalConnectionId(backendId: string): string { return `${DESKTOP_LOCAL_CONNECTION_ID_PREFIX}${backendId}`; diff --git a/apps/web/src/environments/primary/auth.ts b/apps/web/src/environments/primary/auth.ts index d76002a93298..0697fa4fe65c 100644 --- a/apps/web/src/environments/primary/auth.ts +++ b/apps/web/src/environments/primary/auth.ts @@ -9,7 +9,6 @@ import type { } from "@t3tools/contracts"; import { EnvironmentHttpCommonError, PRIMARY_LOCAL_ENVIRONMENT_ID } from "@t3tools/contracts"; import type { EnvironmentHttpCommonError as EnvironmentHttpCommonErrorType } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; import { HttpClientError } from "effect/unstable/http"; @@ -66,7 +65,7 @@ export class PrimaryEnvironmentRequestError extends Schema.TaggedErrorClass()( "PrimaryEnvironmentPairingCredentialRejectedError", @@ -96,10 +95,6 @@ export class PrimaryEnvironmentAuthSessionTimeoutError extends Schema.TaggedErro } } -export const isPrimaryEnvironmentAuthSessionTimeoutError = Schema.is( - PrimaryEnvironmentAuthSessionTimeoutError, -); - export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.TaggedErrorClass()( "PrimaryEnvironmentPairingCredentialRequiredError", { @@ -111,10 +106,6 @@ export class PrimaryEnvironmentPairingCredentialRequiredError extends Schema.Tag } } -export const isPrimaryEnvironmentPairingCredentialRequiredError = Schema.is( - PrimaryEnvironmentPairingCredentialRequiredError, -); - const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); export interface ServerPairingLinkRecord { @@ -386,44 +377,6 @@ export async function createServerPairingCredential(input?: { } } -export async function listServerPairingLinks(): Promise> { - try { - const pairingLinks = await runPrimaryHttp( - PrimaryEnvironmentHttpClient.pipe( - Effect.flatMap((client) => client.auth.pairingLinks({ headers: {} })), - ), - ); - return pairingLinks.map((pairingLink) => { - const timestamps = { - createdAt: DateTime.formatIso(pairingLink.createdAt), - expiresAt: DateTime.formatIso(pairingLink.expiresAt), - }; - if (pairingLink.label === undefined) { - return { - id: pairingLink.id, - scopes: pairingLink.scopes, - subject: pairingLink.subject, - createdAt: timestamps.createdAt, - expiresAt: timestamps.expiresAt, - }; - } - return { - id: pairingLink.id, - scopes: pairingLink.scopes, - subject: pairingLink.subject, - label: pairingLink.label, - createdAt: timestamps.createdAt, - expiresAt: timestamps.expiresAt, - }; - }); - } catch (error) { - throw PrimaryEnvironmentRequestError.fromCause({ - operation: "list-pairing-links", - cause: error, - }); - } -} - export async function revokeServerPairingLink(id: string): Promise { try { await runPrimaryHttp( @@ -440,38 +393,6 @@ export async function revokeServerPairingLink(id: string): Promise { } } -export async function listServerClientSessions(): Promise< - ReadonlyArray -> { - try { - const clientSessions = await runPrimaryHttp( - PrimaryEnvironmentHttpClient.pipe( - Effect.flatMap((client) => client.auth.clients({ headers: {} })), - ), - ); - return clientSessions.map((clientSession) => ({ - sessionId: clientSession.sessionId, - subject: clientSession.subject, - scopes: clientSession.scopes, - method: clientSession.method, - client: clientSession.client, - issuedAt: DateTime.formatIso(clientSession.issuedAt), - expiresAt: DateTime.formatIso(clientSession.expiresAt), - lastConnectedAt: - clientSession.lastConnectedAt === null - ? null - : DateTime.formatIso(clientSession.lastConnectedAt), - connected: clientSession.connected, - current: clientSession.current, - })); - } catch (error) { - throw PrimaryEnvironmentRequestError.fromCause({ - operation: "list-client-sessions", - cause: error, - }); - } -} - export async function revokeServerClientSession(sessionId: AuthSessionId): Promise { try { await runPrimaryHttp( @@ -531,16 +452,6 @@ export async function resolveInitialServerAuthGateState(): Promise { - resolvedAuthenticatedGateState = null; - bootstrapPromise = null; - return resolveInitialServerAuthGateState(); -} - export function __resetServerAuthBootstrapForTests() { bootstrapPromise = null; resolvedAuthenticatedGateState = null; diff --git a/apps/web/src/environments/primary/context.ts b/apps/web/src/environments/primary/context.ts index 48017ac29e38..4bc4cb9f6681 100644 --- a/apps/web/src/environments/primary/context.ts +++ b/apps/web/src/environments/primary/context.ts @@ -54,7 +54,7 @@ async function fetchPrimaryEnvironmentDescriptor(): Promise; -export const remoteHttpRuntime = ManagedRuntime.make(httpClientLayer); - const primaryHttpRuntime = ManagedRuntime.make( PrimaryEnvironmentHttpClient.layer.pipe(Layer.provide(primaryEnvironmentHttpLayer)), ); diff --git a/apps/web/src/observability/clientTracing.ts b/apps/web/src/observability/clientTracing.ts index 95d390b90026..81cd18e207de 100644 --- a/apps/web/src/observability/clientTracing.ts +++ b/apps/web/src/observability/clientTracing.ts @@ -41,15 +41,6 @@ export interface ClientTracingConfig { readonly exportIntervalMs?: number; } -export const ClientTracingLive = Layer.succeed( - Tracer.Tracer, - Tracer.make({ - span(options) { - return activeDelegate?.span(options) ?? new Tracer.NativeSpan(options); - }, - }), -); - export function configureClientTracing(config: ClientTracingConfig = {}): Promise { if (config.exportIntervalMs === undefined && activeConfigKey !== null) { return pendingConfiguration; diff --git a/apps/web/src/rpc/requestLatencyState.ts b/apps/web/src/rpc/requestLatencyState.ts index 9015a3c40b00..2731efecfc85 100644 --- a/apps/web/src/rpc/requestLatencyState.ts +++ b/apps/web/src/rpc/requestLatencyState.ts @@ -112,7 +112,7 @@ export function acknowledgeRpcRequest(requestId: string): void { setSlowRpcAckRequests(slowRequests.filter((request) => request.requestId !== requestId)); } -export function clearAllTrackedRpcRequests(): void { +function clearAllTrackedRpcRequests(): void { for (const pending of pendingRpcAckRequests.values()) { clearTimeout(pending.timeoutId); } diff --git a/apps/web/src/rpc/transportError.ts b/apps/web/src/rpc/transportError.ts index 493de5f93bd2..7d0e4777a3a0 100644 --- a/apps/web/src/rpc/transportError.ts +++ b/apps/web/src/rpc/transportError.ts @@ -1,4 +1 @@ -export { - isTransportConnectionErrorMessage, - sanitizeThreadErrorMessage, -} from "@t3tools/client-runtime/errors"; +export { sanitizeThreadErrorMessage } from "@t3tools/client-runtime/errors"; diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index c44c5b437b63..deb4948a0f5a 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -39,7 +39,7 @@ const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pip Atom.withLabel("web-thread-status:empty"), ); -export const activeEnvironmentIdAtom = Atom.make(null).pipe( +const activeEnvironmentIdAtom = Atom.make(null).pipe( Atom.keepAlive, Atom.withLabel("web-active-environment-id"), ); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cdc..f085075fdd7c 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -11,7 +11,6 @@ import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; -import { useEnvironmentQuery } from "./query"; import { relayEnvironmentDiscovery } from "./relay"; import { usePreparedConnection } from "./session"; @@ -85,7 +84,3 @@ export function useEnvironmentHttpBaseUrl(environmentId: EnvironmentId | null): export function useRelayEnvironmentDiscovery(): Discovery.RelayEnvironmentDiscoveryState { return useAtomValue(relayEnvironmentDiscovery.stateValueAtom); } - -export function useEnvironmentConnectionState(environmentId: EnvironmentId) { - return useEnvironmentQuery(environmentCatalog.stateAtom(environmentId)); -} diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..1792c5e9e599 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -14,7 +14,6 @@ import type { OrchestrationThread, ProjectContentMatch, ProjectEntryKind, - ThreadId, VcsListRefsResult, VcsRef, } from "@t3tools/contracts"; @@ -28,7 +27,6 @@ import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; -import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; @@ -103,35 +101,6 @@ export function useThreadSearch( }; } -export function useThreadDetail( - environmentId: EnvironmentId | null, - threadId: ThreadId | null, -): ThreadDetailView { - const state = useEnvironmentThread(environmentId, threadId); - return { - data: Option.getOrNull(state.data), - error: Option.getOrNull(state.error), - isPending: state.status === "synchronizing", - isDeleted: state.status === "deleted", - }; -} - -export function useBranches(target: VcsRefTarget) { - const query = target.query?.trim() ?? ""; - return useEnvironmentQuery( - target.environmentId !== null && target.cwd !== null - ? vcsEnvironment.listRefs({ - environmentId: target.environmentId, - input: { - cwd: target.cwd, - ...(query.length > 0 ? { query } : {}), - limit: VCS_REF_LIST_LIMIT, - }, - }) - : null, - ); -} - export function usePaginatedBranches(target: VcsRefTarget) { const query = target.query?.trim() ?? ""; const targetKey = diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 31b9436621c9..f13965e5b4d8 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -50,7 +50,7 @@ const EMPTY_PRIMARY_SERVER_STATE: PrimaryServerState = { welcome: null, }; -export const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { +const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { const environmentId = get(primaryEnvironmentIdAtom); if (environmentId === null) { return EMPTY_PRIMARY_SERVER_STATE; diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index 1f88da2f971d..b1719819da9d 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -4,7 +4,6 @@ import { } from "@t3tools/client-runtime/connection"; import { createEnvironmentShellAtoms, - createEnvironmentShellSummaryAtom, createEnvironmentSnapshotAtom, createShellEnvironmentAtoms, type EnvironmentShellState, @@ -21,10 +20,6 @@ import { isHostedStaticApp } from "../hostedPairing"; export const shellEnvironment = createShellEnvironmentAtoms(connectionAtomRuntime); export const environmentShell = createEnvironmentShellAtoms(connectionAtomRuntime); export const environmentSnapshotAtom = createEnvironmentSnapshotAtom(environmentShell.stateAtom); -export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({ - catalogValueAtom: environmentCatalog.catalogValueAtom, - shellStateValueAtom: environmentShell.stateValueAtom, -}); export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom)); diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index fd936f99ff23..c7caaa6a35a7 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -16,7 +16,7 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const threadEnvironment = createThreadEnvironmentAtoms(connectionAtomRuntime); -export const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); +const environmentThreads = createEnvironmentThreadStateAtoms(connectionAtomRuntime); export const environmentThreadDetails = createEnvironmentThreadDetailAtoms( environmentThreads.stateAtom, ); From 226abe5f916ff828d8be2c19f1148b10feebfe43 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:28 -0700 Subject: [PATCH 176/320] refactor(web): keep feature component helpers private (#10226) --- .../BranchToolbarEnvModeSelector.tsx | 2 +- apps/web/src/components/ConfirmDialogHost.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 2 -- .../src/components/EnvironmentMachineIcon.tsx | 4 ++-- apps/web/src/components/LegacySidebar.tsx | 2 +- .../ProviderUpdateLaunchNotification.logic.ts | 4 ++-- apps/web/src/components/Sidebar.logic.ts | 15 ++++--------- .../src/components/ThreadCommandSubtitle.tsx | 3 +-- .../chat/ComposerPendingElementContexts.tsx | 2 +- .../chat/ComposerPendingTerminalContexts.tsx | 22 ------------------- .../chat/ContextWindowMeter.logic.ts | 4 ++-- .../components/chat/MessagesTimeline.logic.ts | 17 +++++++------- .../chat/externalLinkContextMenu.ts | 2 +- .../cloud/CloudEnvironmentConnectList.tsx | 2 +- .../src/components/composerFooterLayout.ts | 2 +- .../files/projectFilesQueryState.ts | 2 +- .../web/src/components/media/MediaActions.tsx | 2 +- .../preview/previewAutomationErrors.ts | 2 +- .../preview/previewMiniPlayerLayout.ts | 2 +- .../src/components/projectScriptEditor.tsx | 2 +- .../pullRequest/PullRequestCodeTab.tsx | 2 +- .../pullRequest/pullRequestLinkContextMenu.ts | 2 +- .../pullRequest/pullRequestList.logic.ts | 2 +- .../pullRequest/pullRequestListPreferences.ts | 2 +- .../settings/KeybindingsSettings.logic.ts | 2 +- .../settings/ProjectSettingsPanel.tsx | 6 ++--- .../settings/SettingsSidebarNav.tsx | 2 +- .../components/settings/ThemeWireframe.tsx | 2 +- .../settings/customModelEditor.logic.ts | 4 ++-- .../components/settings/providerDriverMeta.ts | 4 ++-- .../src/components/settings/themeInspector.ts | 2 +- 31 files changed, 46 insertions(+), 79 deletions(-) diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index c0fa463fe72a..611bd4234a0f 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -18,7 +18,7 @@ import { SelectValue, } from "./ui/select"; -export const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; +const PREVIOUS_WORKTREE_SELECT_VALUE = "previous-worktree"; interface BranchToolbarEnvModeSelectorProps { envLocked: boolean; diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index c169a1eff7fc..7babc62fd2ea 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -23,7 +23,7 @@ type ConfirmationCopy = { readonly description: string | null; }; -export function resolveConfirmDialogCopy(message: string): ConfirmationCopy { +function resolveConfirmDialogCopy(message: string): ConfirmationCopy { const normalizedMessage = message.trim(); const lines = normalizedMessage.split("\n"); const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?")); diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index af6d95dba0b0..d764b9c6a9e2 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -104,8 +104,6 @@ interface DiffPanelProps { workspaceMutationId: string | null; } -export { DiffWorkerPoolProvider } from "./DiffWorkerPoolProvider"; - export default function DiffPanel({ mode = "inline", composerDraftTarget, diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx index 31b7a953a3a4..b42bf41266c9 100644 --- a/apps/web/src/components/EnvironmentMachineIcon.tsx +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -23,7 +23,7 @@ function LucideLike(props: SVGProps) { } /** A Mac mini: squat rounded slab with a front-edge LED. */ -export function MacMiniIcon(props: SVGProps) { +function MacMiniIcon(props: SVGProps) { return ( @@ -33,7 +33,7 @@ export function MacMiniIcon(props: SVGProps) { } /** A Mac Studio: the same slab twice as tall, ports along the front foot. */ -export function MacStudioIcon(props: SVGProps) { +function MacStudioIcon(props: SVGProps) { return ( diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index bacdfa62118c..0eb6a74ebe80 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -354,7 +354,7 @@ interface SidebarThreadRowProps { ) => boolean; } -export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { +const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { const { orderedProjectThreadKeys, isActive, diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 16184ac070bc..6da4eaac6dde 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -220,7 +220,7 @@ export function providerUpdateNotificationKey( return parts.length > 0 ? parts.join("|") : null; } -export function formatProviderList(providers: ReadonlyArray>) { +function formatProviderList(providers: ReadonlyArray>) { const names = providers.map( (provider) => PROVIDER_DISPLAY_NAMES[provider.driver] ?? provider.driver, ); @@ -249,7 +249,7 @@ export function shouldShowPrimaryProviderUpdateToast(view: ProviderUpdateToastVi return view.phase !== "running"; } -export function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { +function getProviderUpdateRunningToastView(providerCount: number): ProviderUpdateToastView { return { phase: "running", type: "loading", diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index d5d7d1f23a44..194db3130011 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -15,7 +15,7 @@ import type { SidebarThreadSummary, Thread } from "../types"; import { cn } from "../lib/utils"; import { isLatestTurnSettled } from "../session-logic"; -export const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; +const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // Visible sidebar rows are prewarmed into the thread-detail cache so opening a // nearby thread usually reuses an already-hot subscription. Each prewarmed @@ -23,10 +23,10 @@ export const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; // activities, growing as agents work) for as long as the row stays visible, // so this limit is a direct renderer-heap and server-load multiplier — keep // it small; cold opens still render instantly from the cached snapshot. -export const SIDEBAR_THREAD_PREWARM_LIMIT = 3; +const SIDEBAR_THREAD_PREWARM_LIMIT = 3; // A small buffer keeps the next few rows warm without leasing every row that // content-visibility leaves mounted below the scroll viewport. -export const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; +const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { readonly leaseLiveStatus: boolean; @@ -544,13 +544,6 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } -/** NaN-safe Date.parse for sort comparators: a malformed timestamp must not - poison the whole ordering, so it sinks to the epoch instead. */ -export function parseTimestampMs(isoDate: string): number { - const parsed = Date.parse(isoDate); - return Number.isNaN(parsed) ? 0 : parsed; -} - /** First VALID timestamp wins: `a ?? b` falls through on null, but a present- yet-malformed string must also fall through to the next candidate rather than sink the row to the epoch. */ @@ -567,7 +560,7 @@ export function firstValidTimestampMs( /** String twin of firstValidTimestampMs for callers that need the ISO string (display labels, tick anchors) rather than epoch ms. */ -export function firstValidTimestamp( +function firstValidTimestamp( ...candidates: ReadonlyArray ): string | null { for (const candidate of candidates) { diff --git a/apps/web/src/components/ThreadCommandSubtitle.tsx b/apps/web/src/components/ThreadCommandSubtitle.tsx index cd5074518719..5890e99d2010 100644 --- a/apps/web/src/components/ThreadCommandSubtitle.tsx +++ b/apps/web/src/components/ThreadCommandSubtitle.tsx @@ -15,8 +15,7 @@ export type ThreadCommandSubtitleVariant = | "favicon-workspace" | "favicon-branch-harness"; -export const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = - "favicon-workspace-harness"; +const THREAD_COMMAND_SUBTITLE_VARIANT: ThreadCommandSubtitleVariant = "favicon-workspace-harness"; export const COMMAND_PALETTE_META_ICON_CLASS = "size-3 shrink-0 text-muted-foreground/70"; diff --git a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx index 7373403a7c39..8d59485b7d15 100644 --- a/apps/web/src/components/chat/ComposerPendingElementContexts.tsx +++ b/apps/web/src/components/chat/ComposerPendingElementContexts.tsx @@ -39,7 +39,7 @@ function buildTooltipContent(context: ElementContextDraft): string { return lines.join("\n"); } -export function ComposerPendingElementContextChip({ +function ComposerPendingElementContextChip({ context, onRemove, }: ComposerPendingElementContextChipProps) { diff --git a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx index 37c05eab2d0d..e2b3109f17a5 100644 --- a/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx +++ b/apps/web/src/components/chat/ComposerPendingTerminalContexts.tsx @@ -1,4 +1,3 @@ -import { cn } from "~/lib/utils"; import { type TerminalContextDraft, formatTerminalContextLabel, @@ -6,11 +5,6 @@ import { } from "~/lib/terminalContext"; import { TerminalContextInlineChip } from "./TerminalContextInlineChip"; -interface ComposerPendingTerminalContextsProps { - contexts: ReadonlyArray; - className?: string; -} - interface ComposerPendingTerminalContextChipProps { context: TerminalContextDraft; } @@ -26,19 +20,3 @@ export function ComposerPendingTerminalContextChip({ return ; } - -export function ComposerPendingTerminalContexts(props: ComposerPendingTerminalContextsProps) { - const { contexts, className } = props; - - if (contexts.length === 0) { - return null; - } - - return ( -
- {contexts.map((context) => ( - - ))} -
- ); -} diff --git a/apps/web/src/components/chat/ContextWindowMeter.logic.ts b/apps/web/src/components/chat/ContextWindowMeter.logic.ts index be3dacb05e92..6ff2b6e0a660 100644 --- a/apps/web/src/components/chat/ContextWindowMeter.logic.ts +++ b/apps/web/src/components/chat/ContextWindowMeter.logic.ts @@ -9,8 +9,8 @@ import { } from "../../providerInstances"; import { getTriggerDisplayModelName, type ModelEsque } from "./providerIconUtils"; -export const CLAUDE_RESUME_COMPACTION_MINUTES = 70; -export const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; +const CLAUDE_RESUME_COMPACTION_MINUTES = 70; +const CLAUDE_RESUME_COMPACTION_TOKENS = 100_000; export function providerSupportsManualCompaction( provider: ProviderInstanceEntry | null | undefined, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 03d1e46faf32..1288cd6fad8b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -14,7 +14,6 @@ import { } from "@t3tools/client-runtime/work-log/presentation"; export { normalizeCompactToolLabel, - summarizeToolGroup, toolGroupAction, } from "@t3tools/client-runtime/work-log/presentation"; import { @@ -31,11 +30,11 @@ import { type ChatMessage, type ProposedPlan, type TurnDiffSummary } from "../.. import { type MessageId, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; import { formatWorkspaceRelativePath } from "../../filePathDisplay"; -export const TIMELINE_MINIMAP_ITEM_SPACING = 8; +const TIMELINE_MINIMAP_ITEM_SPACING = 8; export const TIMELINE_MINIMAP_MIN_ITEMS = 2; -export const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; -export const TIMELINE_CONTENT_MAX_WIDTH = 768; -export const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; +const TIMELINE_MINIMAP_MAX_HEIGHT_CSS = "calc(100vh - 18rem)"; +const TIMELINE_CONTENT_MAX_WIDTH = 768; +const TIMELINE_MINIMAP_PERSISTENT_GUTTER = 48; function singleToolCallLabel(entry: WorkLogEntry): string { const toolPresentation = resolveWorkEntryToolPresentation(entry, "completed"); @@ -145,7 +144,7 @@ export interface TimelineEndState { * A small pixel band (instead of the 1px isAtEnd epsilon alone) keeps re-arming * reliable while streaming content is still growing under the viewport. */ -export const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; +const TIMELINE_FOLLOW_REARM_THRESHOLD_PX = 40; export function resolveTimelineIsAtEnd(state: TimelineEndState | undefined): boolean | undefined { if (!state) { @@ -207,9 +206,9 @@ export function resolveTimelineMinimapHasPersistentGutter(viewportWidth: number) return sideGutter >= TIMELINE_MINIMAP_PERSISTENT_GUTTER; } -export const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; -export const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; -export const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; +const TIMELINE_MINIMAP_HIT_STRIP_LEFT = 12; +const TIMELINE_MINIMAP_HIT_STRIP_MAX_WIDTH = 40; +const TIMELINE_MINIMAP_EXPANDED_HIT_STRIP_WIDTH = "22rem"; /** * The minimap overlays the viewport's left edge while the content column is diff --git a/apps/web/src/components/chat/externalLinkContextMenu.ts b/apps/web/src/components/chat/externalLinkContextMenu.ts index d0f37f97d800..f836f069006d 100644 --- a/apps/web/src/components/chat/externalLinkContextMenu.ts +++ b/apps/web/src/components/chat/externalLinkContextMenu.ts @@ -35,7 +35,7 @@ const EXTERNAL_LINK_CONTEXT_MENU_ITEMS = [ * whole menu with the one item that cannot be honoured is what left a right-click on a link * showing the platform's cut-and-paste menu instead of a way to copy the link. */ -export function externalLinkContextMenuItems(options: { +function externalLinkContextMenuItems(options: { readonly canOpenInPreview: boolean; readonly threadLinkAction?: "link-to-thread" | "unlink-from-thread" | undefined; }): readonly ContextMenuItem[] { diff --git a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx index 7f29c69c3208..747b82d1e552 100644 --- a/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx +++ b/apps/web/src/components/cloud/CloudEnvironmentConnectList.tsx @@ -32,7 +32,7 @@ export interface SavedCloudEnvironmentConnection { readonly connection: EnvironmentConnectionPresentation; } -export function RemoteEnvironmentRowsSkeleton() { +function RemoteEnvironmentRowsSkeleton() { return (
diff --git a/apps/web/src/components/composerFooterLayout.ts b/apps/web/src/components/composerFooterLayout.ts index 2747e72bb029..5dd6000dbc75 100644 --- a/apps/web/src/components/composerFooterLayout.ts +++ b/apps/web/src/components/composerFooterLayout.ts @@ -1,6 +1,6 @@ export const COMPOSER_FOOTER_COMPACT_BREAKPOINT_PX = 620; export const COMPOSER_FOOTER_WIDE_ACTIONS_COMPACT_BREAKPOINT_PX = 780; -export const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; +const RESTING_COMPOSER_IMAGE_THUMBNAIL_LIMIT = 3; export function getRestingComposerImagePreviewCounts(imageCount: number): { visibleCount: number; diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d02ec99605ba..a12772920956 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -33,7 +33,7 @@ interface ProjectQueryState { readonly refresh: () => void; } -export function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { +function getProjectEntriesQueryAtom(environmentId: EnvironmentId, cwd: string) { return projectEnvironment.listEntries({ environmentId, input: { cwd } }); } diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index cf79c81b9f60..d6c5cca70153 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -33,7 +33,7 @@ function mediaFileName(source: MediaActionSource): string { } /** Explicit byte operations get fresh capabilities without replacing a player's active source. */ -export function useMediaActions(source: MediaActionSource) { +function useMediaActions(source: MediaActionSource) { const createAssetUrl = useAtomQueryRunner(assetEnvironment.createUrl, { reportFailure: false, refresh: true, diff --git a/apps/web/src/components/preview/previewAutomationErrors.ts b/apps/web/src/components/preview/previewAutomationErrors.ts index dcf35de53f2d..97a099ec72eb 100644 --- a/apps/web/src/components/preview/previewAutomationErrors.ts +++ b/apps/web/src/components/preview/previewAutomationErrors.ts @@ -216,7 +216,7 @@ export const PreviewAutomationHostError = Schema.Union([ ]); export type PreviewAutomationHostError = typeof PreviewAutomationHostError.Type; -export const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); +const isPreviewAutomationHostError = Schema.is(PreviewAutomationHostError); export function serializePreviewAutomationHostError( error: PreviewAutomationHostError, diff --git a/apps/web/src/components/preview/previewMiniPlayerLayout.ts b/apps/web/src/components/preview/previewMiniPlayerLayout.ts index 3aa2e141af07..10723cedaa8a 100644 --- a/apps/web/src/components/preview/previewMiniPlayerLayout.ts +++ b/apps/web/src/components/preview/previewMiniPlayerLayout.ts @@ -4,7 +4,7 @@ export const PREVIEW_MINI_PLAYER_EDGE_GAP = 12; // The mini-player shell straddles this webview at 47 and 49; dialogs begin at 50. export const PREVIEW_MINI_PLAYER_WEBVIEW_Z_INDEX = 48; export const PREVIEW_MINI_PLAYER_DEFAULT_SIZE = { width: 320, height: 200 } as const; -export const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; +const PREVIEW_MINI_PLAYER_MIN_SIZE = { width: 240, height: 150 } as const; export function clampPreviewMiniPlayerSize( size: PreviewMiniPlayerSize, diff --git a/apps/web/src/components/projectScriptEditor.tsx b/apps/web/src/components/projectScriptEditor.tsx index 4b728c2e07eb..4ffd453955e9 100644 --- a/apps/web/src/components/projectScriptEditor.tsx +++ b/apps/web/src/components/projectScriptEditor.tsx @@ -49,7 +49,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Switch } from "./ui/switch"; import { Textarea } from "./ui/textarea"; -export const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ +const SCRIPT_ICONS: Array<{ id: ProjectScriptIcon; label: string }> = [ { id: "play", label: "Play" }, { id: "test", label: "Test" }, { id: "lint", label: "Lint" }, diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index aa2d278ce249..b77a3711d90f 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -187,7 +187,7 @@ function getReviewPositionAnchor(position: PullRequestReviewPosition): { * host sit under the line they were written on, and a new comment joins the review being * drafted rather than being posted as it is typed. */ -export function PullRequestCodeTab({ +function PullRequestCodeTab({ environmentId, reference, detail, diff --git a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts index ef554d0eb3ee..1ccdb64b73f4 100644 --- a/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts +++ b/apps/web/src/components/pullRequest/pullRequestLinkContextMenu.ts @@ -8,7 +8,7 @@ import { toastManager } from "../ui/toast"; export type PullRequestLinkContextMenuAction = "copy-link" | "open-external"; /** Named for the host rather than "externally": the point is where you will land. */ -export const OPEN_ON_HOST_LABELS: Partial> = { +const OPEN_ON_HOST_LABELS: Partial> = { github: "Open on GitHub", gitlab: "Open on GitLab", bitbucket: "Open on Bitbucket", diff --git a/apps/web/src/components/pullRequest/pullRequestList.logic.ts b/apps/web/src/components/pullRequest/pullRequestList.logic.ts index cee672489389..c2fdde3d011f 100644 --- a/apps/web/src/components/pullRequest/pullRequestList.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestList.logic.ts @@ -67,7 +67,7 @@ export type PullRequestViewers = PullRequestListResult["viewers"]; /** A row plus the environment that read it, where the caller has one to give. */ type ScopedEntry = PullRequestListEntry & { readonly environmentId?: string }; -export const pullRequestViewerKey = (entry: ScopedEntry): string => +const pullRequestViewerKey = (entry: ScopedEntry): string => `${entry.environmentId ?? ""} ${entry.host}`; const GROUP_LABELS: Record = { diff --git a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts index bc2bc6f272cd..95c4bf02e743 100644 --- a/apps/web/src/components/pullRequest/pullRequestListPreferences.ts +++ b/apps/web/src/components/pullRequest/pullRequestListPreferences.ts @@ -37,7 +37,7 @@ export type PullRequestListPreferencePatch = { [Key in keyof PullRequestListPreferences]?: PullRequestListPreferences[Key] | undefined; }; -export const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { +const DEFAULT_PULL_REQUEST_LIST_PREFERENCES = { involvement: "all", state: "open", } as const satisfies PullRequestListPreferences; diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index d987bc7a83dd..c366a87e7efd 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -291,7 +291,7 @@ function titleCaseCommandSegment(segment: string): string { return words.join(" "); } -export function normalizeShortcutKeyToken(key: string): string | null { +function normalizeShortcutKeyToken(key: string): string | null { const normalized = key.toLowerCase(); if ( normalized === "meta" || diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 7be0f15cd5c2..0181041ec6b1 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -127,14 +127,14 @@ const ProjectIconPickerDialog = lazy(() => })), ); -export const PROJECT_GROUPING_MODE_LABELS: Record = { +const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ -export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { +function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -253,7 +253,7 @@ function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { ); } -export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index f8f0254cda61..0e1ff2076e99 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -80,7 +80,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/archived": ArchiveIcon, }; -export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ +const SETTINGS_NAV_ITEMS: ReadonlyArray<{ label: string; to: SettingsPath; icon: ComponentType<{ className?: string }>; diff --git a/apps/web/src/components/settings/ThemeWireframe.tsx b/apps/web/src/components/settings/ThemeWireframe.tsx index ce4f13f208e5..895d8d1eecb7 100644 --- a/apps/web/src/components/settings/ThemeWireframe.tsx +++ b/apps/web/src/components/settings/ThemeWireframe.tsx @@ -4,7 +4,7 @@ import type { ThemeCardPreviewColors } from "./ThemePreviewCircles"; // A simple miniature of the app: sidebar, a short conversation, the // composer, and the orchestrator panel floating over the interface as an // island with horizontal agent rows. -export function ThemeWireframePane({ +function ThemeWireframePane({ colors, clip, }: { diff --git a/apps/web/src/components/settings/customModelEditor.logic.ts b/apps/web/src/components/settings/customModelEditor.logic.ts index 0d48057206df..15de96e2f182 100644 --- a/apps/web/src/components/settings/customModelEditor.logic.ts +++ b/apps/web/src/components/settings/customModelEditor.logic.ts @@ -104,7 +104,7 @@ export const DESCRIPTOR_PRESETS_BY_KIND: Partial< }; let nextKey = 0; -export function newEditorKey(): string { +function newEditorKey(): string { nextKey += 1; return `k${nextKey}`; } @@ -140,7 +140,7 @@ export function emptyEditorChoice(): EditorChoice { * by built-in runtime profiles a custom entry does not have, so they are * dropped rather than stored as a plain option value. */ -export function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { +function descriptorToEditor(descriptor: ProviderOptionDescriptor): EditorDescriptor { const promptInjected = new Set( descriptor.type === "select" ? (descriptor.promptInjectedValues ?? []) : [], ); diff --git a/apps/web/src/components/settings/providerDriverMeta.ts b/apps/web/src/components/settings/providerDriverMeta.ts index a782632b10c8..4bf4da3919ba 100644 --- a/apps/web/src/components/settings/providerDriverMeta.ts +++ b/apps/web/src/components/settings/providerDriverMeta.ts @@ -43,7 +43,7 @@ export interface ProviderClientDefinition { readonly badgeLabel?: string; } -export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ +const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = [ { value: ProviderDriverKind.make("codex"), label: "Codex", @@ -84,7 +84,7 @@ export const PROVIDER_CLIENT_DEFINITIONS: readonly ProviderClientDefinition[] = }, ]; -export const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< +const PROVIDER_CLIENT_DEFINITION_BY_VALUE: Partial< Record > = Object.fromEntries( PROVIDER_CLIENT_DEFINITIONS.map((definition) => [definition.value, definition]), diff --git a/apps/web/src/components/settings/themeInspector.ts b/apps/web/src/components/settings/themeInspector.ts index 9306226b8c86..b790c307a3d9 100644 --- a/apps/web/src/components/settings/themeInspector.ts +++ b/apps/web/src/components/settings/themeInspector.ts @@ -14,7 +14,7 @@ const THEME_PAINT_KIND_ORDER: ReadonlyArray = [ "foreground", ]; -export const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; +const THEME_INSPECTOR_MATCH_ATTRIBUTE = "data-theme-inspector-match"; const THEME_TOKEN_PROBE_ATTRIBUTE = "data-theme-token-probe"; const THEME_TOKEN_PROBE_COLOR = "#01fea7"; From 1200f530bdc8be25ca1c85affa42b6d9c259601c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:29 -0700 Subject: [PATCH 177/320] refactor(web): keep app utilities private and remove dead helpers (#10227) --- apps/web/src/browser/browserViewportActions.ts | 2 +- apps/web/src/browser/webviewCrashRecovery.ts | 4 ++-- apps/web/src/browserFaviconLogic.ts | 2 +- apps/web/src/browserHistoryStore.ts | 4 ++-- apps/web/src/clientPersistenceStorage.ts | 2 +- apps/web/src/composerDraftStore.ts | 6 ++---- apps/web/src/desktopAppActivation.ts | 2 +- apps/web/src/keybindings.ts | 8 -------- apps/web/src/lib/attachmentUploadQueue.ts | 2 +- apps/web/src/lib/diffRendering.ts | 4 ++-- apps/web/src/lib/previewAnnotation.ts | 2 +- apps/web/src/lib/storage.ts | 2 +- apps/web/src/lib/terminalContext.ts | 7 ++----- apps/web/src/lib/windowControlsOverlay.ts | 2 +- apps/web/src/logicalProject.ts | 1 - apps/web/src/portDiscoveryState.ts | 2 +- apps/web/src/projectIconOptions.ts | 2 +- apps/web/src/reviewCommentContext.ts | 6 ------ apps/web/src/rightPanelStore.ts | 4 ++-- apps/web/src/test/reactHookHarness.ts | 2 +- apps/web/src/themePalette.ts | 11 +++-------- apps/web/src/versionSkew.ts | 2 +- 22 files changed, 27 insertions(+), 52 deletions(-) diff --git a/apps/web/src/browser/browserViewportActions.ts b/apps/web/src/browser/browserViewportActions.ts index b80f68af3f00..64a4345dfb0d 100644 --- a/apps/web/src/browser/browserViewportActions.ts +++ b/apps/web/src/browser/browserViewportActions.ts @@ -4,7 +4,7 @@ type BrowserViewportHandler = (setting: PreviewViewportSetting) => Promise export const BROWSER_VIEWPORT_COMMIT_TIMEOUT_MS = 15_000; -export class BrowserViewportCommitTimeoutError extends Error { +class BrowserViewportCommitTimeoutError extends Error { override readonly name = "BrowserViewportCommitTimeoutError"; constructor(readonly tabId: string) { diff --git a/apps/web/src/browser/webviewCrashRecovery.ts b/apps/web/src/browser/webviewCrashRecovery.ts index 2267f4a812dc..606244d43643 100644 --- a/apps/web/src/browser/webviewCrashRecovery.ts +++ b/apps/web/src/browser/webviewCrashRecovery.ts @@ -1,6 +1,6 @@ export const WEBVIEW_CRASH_RECOVERY_WINDOW_MS = 30_000; -export const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; -export const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; +const WEBVIEW_CRASH_RECOVERY_MAX_ATTEMPTS = 3; +const WEBVIEW_CRASH_RECOVERY_BASE_DELAY_MS = 250; export interface WebviewCrashRecoveryState { readonly attempts: number; diff --git a/apps/web/src/browserFaviconLogic.ts b/apps/web/src/browserFaviconLogic.ts index 695bcff20e95..55adc129c3b2 100644 --- a/apps/web/src/browserFaviconLogic.ts +++ b/apps/web/src/browserFaviconLogic.ts @@ -9,7 +9,7 @@ export type BrowserFaviconEntry = { }; export const BROWSER_FAVICON_MAX_ENTRIES = 40; -export const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; +const BROWSER_FAVICON_MAX_KEY_LENGTH = 4_096; const BROWSER_FAVICON_MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000; export const BROWSER_FAVICON_MAX_ALIASES_PER_ENTRY = 4; const BROWSER_FAVICON_MAX_ALIAS_LENGTH = 255; diff --git a/apps/web/src/browserHistoryStore.ts b/apps/web/src/browserHistoryStore.ts index 4c0a560817bb..7909fef95700 100644 --- a/apps/web/src/browserHistoryStore.ts +++ b/apps/web/src/browserHistoryStore.ts @@ -14,7 +14,7 @@ export type BrowserHistoryEntry = { url: string; lastVisitedAt: number; title?: export const BROWSER_HISTORY_MAX_ENTRIES_PER_PROJECT = 50; export const BROWSER_HISTORY_MAX_PROJECTS = 20; -export const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; +const BROWSER_HISTORY_MAX_URL_LENGTH = 2048; export const BROWSER_HISTORY_MAX_TITLE_LENGTH = 512; const MAX_VALID_DATE_MS = 8_640_000_000_000_000; @@ -35,7 +35,7 @@ export function normalizeHistoryUrl(raw: string): string | null { return parsed.href.length > BROWSER_HISTORY_MAX_URL_LENGTH ? null : parsed.href; } -export function titleLookupKey(normalized: string, environmentHostname?: string | null): string { +function titleLookupKey(normalized: string, environmentHostname?: string | null): string { const parsed = new URL(visitLookupKey(normalized, environmentHostname)); if (parsed.pathname !== "/" && parsed.pathname.endsWith("/")) parsed.pathname = parsed.pathname.slice(0, -1); diff --git a/apps/web/src/clientPersistenceStorage.ts b/apps/web/src/clientPersistenceStorage.ts index f39ea63c5a7c..e1c1459facb3 100644 --- a/apps/web/src/clientPersistenceStorage.ts +++ b/apps/web/src/clientPersistenceStorage.ts @@ -2,7 +2,7 @@ import { ClientSettingsSchema, type ClientSettings } from "@t3tools/contracts"; import { getLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; -export const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; +const CLIENT_SETTINGS_STORAGE_KEY = "t3code:client-settings:v1"; function hasWindow(): boolean { return typeof window !== "undefined"; diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 3adb6e45c2ae..9dbc7b9b5c21 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -769,7 +769,7 @@ const EMPTY_THREAD_DRAFT = Object.freeze({ * slice — adding a new field to the interface (e.g. `elementContexts`) only * has to be reflected here, not in every stub. */ -export function createEmptyThreadDraft(): ComposerThreadDraftState { +function createEmptyThreadDraft(): ComposerThreadDraftState { return { prompt: "", images: [], @@ -4050,9 +4050,7 @@ export function useThreadHasUnsentDraft(threadRef: ScopedThreadRef): boolean { ); } -export function useComposerDraftModelState( - threadRef: ComposerThreadTarget, -): ComposerDraftModelState { +function useComposerDraftModelState(threadRef: ComposerThreadTarget): ComposerDraftModelState { return useComposerDraftStore( useShallow((state) => { const draft = getComposerDraftState(state, threadRef); diff --git a/apps/web/src/desktopAppActivation.ts b/apps/web/src/desktopAppActivation.ts index e9d3a4d1d0f7..d291e7ed54e2 100644 --- a/apps/web/src/desktopAppActivation.ts +++ b/apps/web/src/desktopAppActivation.ts @@ -44,7 +44,7 @@ function failure( return { version: 1, requestId, ok: false, code, message }; } -export function desktopPlatformToEnvironmentOs( +function desktopPlatformToEnvironmentOs( platform: DesktopAppActivationRequest["platform"], ): ExecutionEnvironmentPlatformOs { return platform === "win32" ? "windows" : platform; diff --git a/apps/web/src/keybindings.ts b/apps/web/src/keybindings.ts index 1b4fa072d5a1..844984d4a02e 100644 --- a/apps/web/src/keybindings.ts +++ b/apps/web/src/keybindings.ts @@ -377,14 +377,6 @@ export function isDiffToggleShortcut( return matchesCommandShortcut(event, keybindings, "diff.toggle", options); } -export function isPreviewRefreshShortcut( - event: ShortcutEventLike, - keybindings: ResolvedKeybindingsConfig, - options?: ShortcutMatchOptions, -): boolean { - return matchesCommandShortcut(event, keybindings, "preview.refresh", options); -} - export function isOpenFavoriteEditorShortcut( event: ShortcutEventLike, keybindings: ResolvedKeybindingsConfig, diff --git a/apps/web/src/lib/attachmentUploadQueue.ts b/apps/web/src/lib/attachmentUploadQueue.ts index 79c1092f94d0..d62aee512887 100644 --- a/apps/web/src/lib/attachmentUploadQueue.ts +++ b/apps/web/src/lib/attachmentUploadQueue.ts @@ -445,7 +445,7 @@ export function startAttachmentUpload(input: { * persisted draft upload survives cancellation (an environment switch cancels * the old job, and the draft still references that server copy). */ -export function cancelAttachmentUpload(imageId: string): void { +function cancelAttachmentUpload(imageId: string): void { const job = jobsByImageId.get(imageId); if (!job) { return; diff --git a/apps/web/src/lib/diffRendering.ts b/apps/web/src/lib/diffRendering.ts index 7d031e537c65..2866e88f45f6 100644 --- a/apps/web/src/lib/diffRendering.ts +++ b/apps/web/src/lib/diffRendering.ts @@ -1,7 +1,7 @@ import { parsePatchFiles } from "@pierre/diffs/utils/parsePatchFiles"; import type { FileDiffMetadata } from "@pierre/diffs/types"; -export const DIFF_THEME_NAMES = { +const DIFF_THEME_NAMES = { light: "pierre-light", dark: "pierre-dark", } as const; @@ -81,7 +81,7 @@ interface RenderablePatchOptions { compactPartialHunkOffsets?: boolean; } -export function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { +function compactPartialHunkOffsets(file: FileDiffMetadata): FileDiffMetadata { if (!file.isPartial) return file; let splitLineStart = 0; diff --git a/apps/web/src/lib/previewAnnotation.ts b/apps/web/src/lib/previewAnnotation.ts index 464c8c8a94d4..beabc856f60e 100644 --- a/apps/web/src/lib/previewAnnotation.ts +++ b/apps/web/src/lib/previewAnnotation.ts @@ -111,7 +111,7 @@ async function previewAnnotationScreenshotFile( } /** Upper bound on turning a picked element's crop into a composer attachment. */ -export const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; +const PREVIEW_ANNOTATION_CAPTURE_TIMEOUT_MS = 5_000; export type PreviewAnnotationCapture = /** The crop is ready to attach. */ diff --git a/apps/web/src/lib/storage.ts b/apps/web/src/lib/storage.ts index 87b9b12ea8bc..4c409a3b1ca1 100644 --- a/apps/web/src/lib/storage.ts +++ b/apps/web/src/lib/storage.ts @@ -26,7 +26,7 @@ export function createMemoryStorage(): StateStorage { }; } -export function isStateStorage( +function isStateStorage( storage: Partial | null | undefined, ): storage is StateStorage { return ( diff --git a/apps/web/src/lib/terminalContext.ts b/apps/web/src/lib/terminalContext.ts index 4cdbc019255d..68d85f08d3b1 100644 --- a/apps/web/src/lib/terminalContext.ts +++ b/apps/web/src/lib/terminalContext.ts @@ -65,7 +65,7 @@ export function filterTerminalContextsWithText( return contexts.filter((context) => hasTerminalContextText(context)); } -export function normalizeTerminalContextSelection( +function normalizeTerminalContextSelection( selection: TerminalContextSelection, ): TerminalContextSelection | null { const text = normalizeTerminalContextText(selection.text); @@ -85,10 +85,7 @@ export function normalizeTerminalContextSelection( }; } -export function formatTerminalContextRange(selection: { - lineStart: number; - lineEnd: number; -}): string { +function formatTerminalContextRange(selection: { lineStart: number; lineEnd: number }): string { return selection.lineStart === selection.lineEnd ? `line ${selection.lineStart}` : `lines ${selection.lineStart}-${selection.lineEnd}`; diff --git a/apps/web/src/lib/windowControlsOverlay.ts b/apps/web/src/lib/windowControlsOverlay.ts index 42f9f13c7cda..7c9e8e8553b3 100644 --- a/apps/web/src/lib/windowControlsOverlay.ts +++ b/apps/web/src/lib/windowControlsOverlay.ts @@ -43,7 +43,7 @@ export function syncDocumentWindowControlsOverlayClass(): () => void { }; } -export function getElectronPlatformClassNames( +function getElectronPlatformClassNames( platform: string, ): | readonly [typeof ELECTRON_CLASS_NAME] diff --git a/apps/web/src/logicalProject.ts b/apps/web/src/logicalProject.ts index 41df8c2013c4..d75c4c2de902 100644 --- a/apps/web/src/logicalProject.ts +++ b/apps/web/src/logicalProject.ts @@ -4,7 +4,6 @@ export { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, derivePhysicalProjectKeyFromPath, - deriveProjectGroupLabel, deriveProjectGroupingOverrideKey, getProjectOrderKey, resolveProjectGroupingMode, diff --git a/apps/web/src/portDiscoveryState.ts b/apps/web/src/portDiscoveryState.ts index a5623be4d0fe..206dea56d468 100644 --- a/apps/web/src/portDiscoveryState.ts +++ b/apps/web/src/portDiscoveryState.ts @@ -43,7 +43,7 @@ export function boundConfiguredLocalServerUrls( return bounded; } -export function useDiscoveredPorts( +function useDiscoveredPorts( environmentId: EnvironmentId | null, configuredUrls?: ReadonlyArray, ): ReadonlyArray { diff --git a/apps/web/src/projectIconOptions.ts b/apps/web/src/projectIconOptions.ts index 9f2fc6c028be..a2213fdd4bd2 100644 --- a/apps/web/src/projectIconOptions.ts +++ b/apps/web/src/projectIconOptions.ts @@ -1,7 +1,7 @@ import { iconNames, type IconName } from "lucide-react/dynamic"; export { PROJECT_ICON_COLORS, projectIconColorClassName } from "./projectIconColors"; -export const POPULAR_PROJECT_ICONS = [ +const POPULAR_PROJECT_ICONS = [ "folder-code", "code-2", "terminal", diff --git a/apps/web/src/reviewCommentContext.ts b/apps/web/src/reviewCommentContext.ts index 41f75eb384f1..d66ca4789a48 100644 --- a/apps/web/src/reviewCommentContext.ts +++ b/apps/web/src/reviewCommentContext.ts @@ -180,12 +180,6 @@ export function parseReviewCommentMessageSegments( return segments; } -export function hasReviewCommentMessageSegments(value: string): boolean { - return parseReviewCommentMessageSegments(value).some( - (segment) => segment.kind === "review-comment", - ); -} - export function formatReviewCommentFence(language: string, contents: string): string { const longestBacktickRun = Math.max( 0, diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index bc7da2d5a9e7..1173cae3ef38 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -14,7 +14,7 @@ import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = [ +const RIGHT_PANEL_KINDS = [ "diff", "files", "file", @@ -193,7 +193,7 @@ export function pullRequestSurfaceId(target: { return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`; } -export function pullRequestSurface(target: { +function pullRequestSurface(target: { environmentId?: string; projectId: string; repository: string; diff --git a/apps/web/src/test/reactHookHarness.ts b/apps/web/src/test/reactHookHarness.ts index 1b4b26fb6988..3a9bf9484ea1 100644 --- a/apps/web/src/test/reactHookHarness.ts +++ b/apps/web/src/test/reactHookHarness.ts @@ -34,7 +34,7 @@ import type { Dispatch, SetStateAction } from "react"; * Call `beginRender()` before each component invocation and `reset()` in * `beforeEach` to drop persisted state between tests. */ -export function createReactHookHarness() { +function createReactHookHarness() { let cursor = 0; let slots: unknown[] = []; const nextIndex = () => cursor++; diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index f4b5c83df395..ef776dced9b7 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -22,15 +22,10 @@ export { EMBER_THEME, GROVE_THEME, IRIS_THEME, OCEAN_THEME, T3_CHAT_THEME, THEME export type { ThemeAppearance, ThemeColorRole, ThemeColors, ThemeDefinition, ThemeVariants }; export const T3_CHAT_THEME_ID = "t3-chat" as const; -export const T3_CHAT_THEME_LABEL = "T3 Chat"; -export const GROVE_THEME_ID = "grove" as const; -export const GROVE_THEME_LABEL = "Grove"; +const GROVE_THEME_ID = "grove" as const; export const OCEAN_THEME_ID = "ocean" as const; -export const OCEAN_THEME_LABEL = "Ocean"; -export const EMBER_THEME_ID = "ember" as const; -export const EMBER_THEME_LABEL = "Ember"; -export const IRIS_THEME_ID = "iris" as const; -export const IRIS_THEME_LABEL = "Iris"; +const EMBER_THEME_ID = "ember" as const; +const IRIS_THEME_ID = "iris" as const; export const THEME_FILE_VERSION = 1 as const; export const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1"; export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system"; diff --git a/apps/web/src/versionSkew.ts b/apps/web/src/versionSkew.ts index d595b12b618e..0c889f17c535 100644 --- a/apps/web/src/versionSkew.ts +++ b/apps/web/src/versionSkew.ts @@ -12,7 +12,7 @@ export interface VersionMismatch { readonly hint: string; } -export const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; +const VERSION_MISMATCH_DISMISSALS_STORAGE_KEY = "t3code:version-mismatch-dismissals:v1"; // Runtime failures retain their identity until the next attempt. Dismiss only // that attempt, across chat remounts, without clearing the error in Settings. From da2ba5b81f8be6afc6d54e728bf4ac8781f3ab1c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:07:29 -0700 Subject: [PATCH 178/320] ci: enforce unused runtime exports in the web app (#10228) --- docs/operations/development.md | 5 ++++- knip.jsonc | 4 ++++ package.json | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/operations/development.md b/docs/operations/development.md index 8016a777b223..e07758caa657 100644 --- a/docs/operations/development.md +++ b/docs/operations/development.md @@ -72,10 +72,13 @@ Windows investigation while that suite is not a required gate. ### Unused code `vp run knip:check` checks unused files and dependencies across the repo, then -unused runtime exports in every internal package under `packages/`. CI enforces both checks. +unused runtime exports in `apps/web` and every internal package under `packages/`. +CI enforces both checks. Exported types and Effect schemas are allowed without consumers. The schema preprocessor recognizes schema types, including aliases and schema classes; functions that create or decode schemas remain checked. Completely unused files remain checked too. +Named exports in web UI component modules are kept as complete component sets. Knip ignores +unused exports in `apps/web/src/components/ui/*.tsx`, while still reporting an entire unused file. Use `vp run knip --workspace apps/web` to audit one workspace, including exports, or `vp run knip:production --workspace apps/web` to find code kept alive only by tests. The full export audit still has findings and is not a repo-wide CI gate. Extend the diff --git a/knip.jsonc b/knip.jsonc index aa665d09602c..c6ba319da5b1 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -43,6 +43,10 @@ "apps/web": { // Worktree setup invokes this directly from t3.json. "entry": ["scripts/warm-dep-cache.ts"], + // UI component modules are copied and adapted as cohesive sets. Keep their + // named subcomponents even before they have callers; the file audit still + // reports an entire component module when nothing imports it. + "ignoreIssues": { "src/components/ui/*.tsx": ["exports", "nsExports", "duplicates"] }, }, "apps/mobile": { // Expo loads local config plugins by string; Metro handles platform variants. diff --git a/package.json b/package.json index 5e72b463a10a..4e5aca36d135 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "tc": "vp run -r --concurrency-limit 2 typecheck", "lint": "vp lint --report-unused-disable-directives", "knip": "knip --preprocessor ./scripts/knip-schemas.ts", - "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", + "knip:check": "knip --include files,dependencies --no-config-hints && knip --workspace apps/web --workspace packages/client-runtime --workspace packages/contracts --workspace packages/effect-acp --workspace packages/effect-codex-app-server --workspace packages/shared --workspace packages/ssh --workspace packages/tailscale --exports --preprocessor ./scripts/knip-schemas.ts --no-config-hints", "knip:production": "knip --production --preprocessor ./scripts/knip-schemas.ts", "lint:mobile": "node scripts/mobile-native-static-check.ts", "test": "vp run -r test", From 76f686d03456539f41ef295f0071fecb5df08921 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:11:50 -0700 Subject: [PATCH 179/320] test(desktop): cover Clerk setup through the service (#10284) --- apps/desktop/src/app/DesktopClerk.test.ts | 34 ----------------------- apps/desktop/src/app/DesktopClerk.ts | 4 +-- 2 files changed, 2 insertions(+), 36 deletions(-) diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 2f61ca909aef..1641149e9e35 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -63,17 +63,6 @@ describe("DesktopClerk", () => { storageMock.mockReset(); }); - it("derives the Clerk Frontend API hostname used by the desktop CSP", () => { - const publishableKey = `pk_test_${btoa("clerk.t3.codes$")}`; - - assert.equal( - DesktopClerk.resolveDesktopClerkFrontendApiHostname(publishableKey), - "clerk.t3.codes", - ); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname(""), undefined); - assert.equal(DesktopClerk.resolveDesktopClerkFrontendApiHostname("invalid"), undefined); - }); - it.effect("acquires and releases the SDK bridge with the layer", () => { const cleanup = vi.fn(); const events: string[] = []; @@ -208,27 +197,4 @@ describe("DesktopClerk", () => { Effect.provideService(ElectronWindow.ElectronWindow, electronWindow), ); }); - - it.each([ - { isDevelopment: true, scheme: "t3code-dev" }, - { isDevelopment: false, scheme: "t3code" }, - ])("configures the SDK with the $scheme renderer origin", ({ isDevelopment, scheme }) => { - const bridge = { cleanup: vi.fn(), isPrimaryInstance: true }; - storageMock.mockReturnValue(storageAdapter); - createClerkBridgeMock.mockReturnValue(bridge); - - assert.equal(DesktopClerk.createDesktopClerkBridge("/tmp/t3-state", isDevelopment), bridge); - assert.deepEqual(storageMock.mock.calls, [[{ path: "/tmp/t3-state" }]]); - assert.deepEqual(createClerkBridgeMock.mock.calls, [ - [ - { - storage: storageAdapter, - passkeys: true, - renderer: { scheme, host: "app" }, - }, - ], - ]); - storageMock.mockClear(); - createClerkBridgeMock.mockClear(); - }); }); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index 9611dc083d2f..d3c99e5e1d24 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -53,7 +53,7 @@ export class DesktopClerk extends Context.Service< } >()("@t3tools/desktop/app/DesktopClerk") {} -export function resolveDesktopClerkFrontendApiHostname( +function resolveDesktopClerkFrontendApiHostname( publishableKey: string | undefined, ): string | undefined { const normalizedKey = publishableKey?.trim(); @@ -72,7 +72,7 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -export function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { +function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean) { return createClerkBridge({ storage: storage({ path: stateDir }), passkeys: true, From cabac780f5d9566ea26d340c9f126a46664b698b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:12:12 -0700 Subject: [PATCH 180/320] test(desktop): cover WSL hashes through runtime resolution (#10285) --- .../src/backend/DesktopBackendConfiguration.test.ts | 8 -------- apps/desktop/src/backend/DesktopBackendConfiguration.ts | 2 +- 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts index accfdf70b3a3..747663b80ac0 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.test.ts @@ -218,14 +218,6 @@ const withPackagedWslHarness = ( }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); describe("DesktopBackendConfiguration", () => { - it("accepts only normalized SHA-256 archive identities", () => { - assert.equal( - DesktopBackendConfiguration.parseWslRuntimeArchiveHash(` ${"A".repeat(64)}\n`), - "a".repeat(64), - ); - assert.isNull(DesktopBackendConfiguration.parseWslRuntimeArchiveHash("abc123")); - }); - it.effect("resolvePrimary produces a stable scoped bootstrap token", () => withHarness( Effect.gen(function* () { diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..7a8dc8334cf1 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -243,7 +243,7 @@ const WSL_RUNTIME_ARCHIVE_NAME = "wsl-runtime.tar.gz"; const WSL_RUNTIME_ARCHIVE_HASH_NAME = `${WSL_RUNTIME_ARCHIVE_NAME}.sha256`; const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/i; -export const parseWslRuntimeArchiveHash = (value: string): string | null => { +const parseWslRuntimeArchiveHash = (value: string): string | null => { const trimmed = value.trim(); return SHA256_HEX_PATTERN.test(trimmed) ? trimmed.toLowerCase() : null; }; From 181e45110f2d14950a5c1ec415faf3a049cb9b2e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:12:27 -0700 Subject: [PATCH 181/320] test(desktop): cover password store through startup (#10287) --- .../src/app/DesktopPreReadyPlatform.test.ts | 53 ++++++------------- .../src/app/DesktopPreReadyPlatform.ts | 2 +- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts index a29e0fd3baf6..a180f45937d8 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.test.ts @@ -37,45 +37,22 @@ describe("DesktopPreReadyPlatform", () => { registerSchemesMock.mockReset(); }); - it("reads an explicit Electron command-line switch value", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: (switchName) => switchName === "password-store", - getSwitchValue: (switchName) => { - assert.equal(switchName, "password-store"); - return "basic"; - }, - }, - "password-store", + it.effect("preserves an explicit Linux password-store switch", () => { + hasSwitchMock.mockImplementation((switchName) => switchName === "password-store"); + getSwitchValueMock.mockReturnValue(" basic "); + + return Effect.gen(function* () { + const options = yield* DesktopPreReadyPlatform.DesktopPreReadyElectronOptions; + + assert.equal(options.linuxPasswordStoreCommandLine, "basic"); + assert.isFalse(appendSwitchMock.mock.calls.some(([name]) => name === "password-store")); + }).pipe( + Effect.provide( + DesktopPreReadyPlatform.layer.pipe( + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + ), + ), ); - - assert.equal(value, "basic"); - }); - - it("treats valueless Electron command-line switches as absent", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => true, - getSwitchValue: () => "", - }, - "password-store", - ); - - assert.isNull(value); - }); - - it("returns null for missing Electron command-line switches", () => { - const value = DesktopPreReadyPlatform.readCommandLineSwitchValue( - { - hasSwitch: () => false, - getSwitchValue: () => { - throw new Error("Unexpected switch value read."); - }, - }, - "password-store", - ); - - assert.isNull(value); }); it.effect( diff --git a/apps/desktop/src/app/DesktopPreReadyPlatform.ts b/apps/desktop/src/app/DesktopPreReadyPlatform.ts index 7d145632d0bb..718f54115065 100644 --- a/apps/desktop/src/app/DesktopPreReadyPlatform.ts +++ b/apps/desktop/src/app/DesktopPreReadyPlatform.ts @@ -17,7 +17,7 @@ export interface DesktopPreReadyCommandLineReader { readonly getSwitchValue: (switchName: string) => string; } -export function readCommandLineSwitchValue( +function readCommandLineSwitchValue( commandLine: DesktopPreReadyCommandLineReader, switchName: string, ): string | null { From 0c200c5f83c039f85da0cb1b8d1611db3e708c92 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:13:24 -0700 Subject: [PATCH 182/320] test(desktop): cover WSL paths through public behavior (#10289) --- apps/desktop/src/wsl/wslPathParsing.test.ts | 32 ++------------------- apps/desktop/src/wsl/wslPathParsing.ts | 7 ++--- 2 files changed, 5 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/wsl/wslPathParsing.test.ts b/apps/desktop/src/wsl/wslPathParsing.test.ts index 41e358227e1e..dd750164b381 100644 --- a/apps/desktop/src/wsl/wslPathParsing.test.ts +++ b/apps/desktop/src/wsl/wslPathParsing.test.ts @@ -1,11 +1,9 @@ import { describe, it, expect } from "vite-plus/test"; import { - DISTRO_NAME_PATTERN, extractDistroFromUncPath, isValidDistroName, parseWslDistroList, - resolveWslHomeUncPath, resolveWslPickFolderDefaultPath, wslUncPathToLinuxPath, } from "./wslPathParsing.ts"; @@ -116,29 +114,6 @@ describe("wslUncPathToLinuxPath", () => { }); }); -describe("resolveWslHomeUncPath", () => { - const distros = [ - { name: "Debian", isDefault: true, version: 2 as const }, - { name: "Ubuntu", isDefault: false, version: 2 as const }, - ]; - - it("uses the configured distro when one is selected", () => { - expect(resolveWslHomeUncPath({ distro: "Ubuntu" }, distros)).toBe( - "\\\\wsl.localhost\\Ubuntu\\home", - ); - }); - - it("uses the actual default distro when config uses the WSL default", () => { - expect(resolveWslHomeUncPath({ distro: null }, distros)).toBe( - "\\\\wsl.localhost\\Debian\\home", - ); - }); - - it("omits the default path when no default distro is known", () => { - expect(resolveWslHomeUncPath({ distro: null }, [])).toBeNull(); - }); -}); - describe("resolveWslPickFolderDefaultPath", () => { const config = { distro: null }; const distros = [{ name: "Debian", isDefault: true, version: 2 as const }]; @@ -184,23 +159,22 @@ describe("resolveWslPickFolderDefaultPath", () => { }); }); -describe("DISTRO_NAME_PATTERN / isValidDistroName", () => { +describe("isValidDistroName", () => { it("accepts common distro names", () => { for (const name of ["Ubuntu", "Ubuntu-22.04", "kali-linux", "Debian", "Ubuntu 22.04"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(true); expect(isValidDistroName(name)).toBe(true); } }); it("rejects names with trailing whitespace, hyphen, or dot", () => { for (const name of ["Ubuntu ", "Ubuntu-", "Ubuntu."]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); it("rejects names containing control or shell-meta characters", () => { for (const name of ["bad\nname", "bad\tname", "bad/name", "bad!name", "bad;name"]) { - expect(DISTRO_NAME_PATTERN.test(name)).toBe(false); + expect(isValidDistroName(name)).toBe(false); } }); }); diff --git a/apps/desktop/src/wsl/wslPathParsing.ts b/apps/desktop/src/wsl/wslPathParsing.ts index edbab81f6dc2..baae217c823d 100644 --- a/apps/desktop/src/wsl/wslPathParsing.ts +++ b/apps/desktop/src/wsl/wslPathParsing.ts @@ -10,7 +10,7 @@ export interface WslConfig { // Literal space — \s would also match \n/\t/\r and corrupt UNC paths like \\wsl.localhost\\... // Trailing char must also be \w so hand-edited config like "Ubuntu " / "Ubuntu-" / "Ubuntu." rejects. -export const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; +const DISTRO_NAME_PATTERN = /^\w(?:[\w \-.]*\w)?$/; export function parseWslDistroList(stdout: Buffer): readonly WslDistro[] { const hasUtf16Bom = stdout.length >= 2 && stdout[0] === 0xff && stdout[1] === 0xfe; @@ -61,10 +61,7 @@ export function wslUncPathToLinuxPath(windowsPath: string): string | null { return `/${rest.split("\\").filter(Boolean).join("/")}`; } -export function resolveWslHomeUncPath( - config: WslConfig, - distros: readonly WslDistro[], -): string | null { +function resolveWslHomeUncPath(config: WslConfig, distros: readonly WslDistro[]): string | null { const distroName = config.distro ?? distros.find((distro) => distro.isDefault)?.name ?? null; return distroName ? `\\\\wsl.localhost\\${distroName}\\home` : null; } From b4040d9bf38d57cce715c9e602de8de4b0f4a523 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:30:43 -0700 Subject: [PATCH 183/320] test(desktop): exercise WSL cache safety through public scripts (#10301) --- .../src/wsl/DesktopWslEnvironment.test.ts | 55 ++++--------------- apps/desktop/src/wsl/DesktopWslEnvironment.ts | 9 ++- 2 files changed, 16 insertions(+), 48 deletions(-) diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts index 9dbe43b9650d..e1188e1a3387 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.test.ts @@ -12,21 +12,17 @@ import * as TestClock from "effect/testing/TestClock"; import { ChildProcessSpawner } from "effect/unstable/process"; import { - buildWslNodeEnvPreamble, buildWslRuntimeInstallScript, buildWslRuntimeInvalidateScript, buildWslRuntimePruneScript, DesktopWslDistroListError, formatMissingToolsReason, - formatNodePtyProbeFailureReason, - formatWslShellTransportFailureReason, parseNodePath, parseNodeVersion, parseResolvedPath, parseToolchainReport, parseWslRuntimeRoot, probeWslDistros, - sanitizeWslRuntimeId, } from "./DesktopWslEnvironment.ts"; const encoder = new TextEncoder(); @@ -144,46 +140,19 @@ describe("probeWslDistros", () => { }); }); -describe("formatNodePtyProbeFailureReason", () => { - it("identifies a packaged build that omitted the Linux node-pty prebuild", () => { - const reason = formatNodePtyProbeFailureReason(4); - - expect(reason).toContain("packaged Linux node-pty binary was not included"); - expect(reason).toContain("--wsl-prebuild"); - }); - - it("leaves other node-pty load failures to the compatibility diagnostic", () => { - expect(formatNodePtyProbeFailureReason(1)).toBeNull(); - }); -}); - -describe("formatWslShellTransportFailureReason", () => { - it("distinguishes timeouts and spawn failures from normal shell exit codes", () => { - expect(formatWslShellTransportFailureReason("timeout")).toContain("timed out"); - expect(formatWslShellTransportFailureReason("spawn")).toContain("could not start wsl.exe"); - expect(formatWslShellTransportFailureReason("process")).toContain("lost communication"); - expect(formatWslShellTransportFailureReason(null)).toBeNull(); - }); -}); - -describe("buildWslNodeEnvPreamble", () => { - it("passes the required Node engine range into the shared resolver", () => { - const preamble = buildWslNodeEnvPreamble("^22.16 || ^23.11 || >=24.10"); - - expect(preamble).toContain("T3_NODE_ENGINE_RANGE='^22.16 || ^23.11 || >=24.10'"); - expect(preamble.indexOf("T3_NODE_ENGINE_RANGE=")).toBeLessThan( - preamble.lastIndexOf("ensure_remote_node_path || true"), - ); - }); - - it("keeps the shared resolver permissive when no Node engine range is provided", () => { - expect(buildWslNodeEnvPreamble()).toContain("T3_NODE_ENGINE_RANGE=''"); - }); -}); - describe("WSL runtime cache", () => { - it("sanitizes cache ids before interpolating them into Linux paths", () => { - expect(sanitizeWslRuntimeId("1.2.3/x64; touch /tmp/nope")).toBe("1.2.3_x64__touch__tmp_nope"); + it.each([ + [ + "install", + (id: string) => buildWslRuntimeInstallScript("/runtime.tar.gz", id, "b".repeat(64)), + ], + ["prune", buildWslRuntimePruneScript], + ["invalidate", buildWslRuntimeInvalidateScript], + ] as const)("sanitizes cache ids in the %s script", (_, buildScript) => { + const runtimeId = "1.2.3/x64; touch /tmp/nope"; + const script = buildScript(runtimeId); + expect(script).toContain("/1.2.3_x64__touch__tmp_nope"); + expect(script).not.toContain(runtimeId); }); it("installs through a temporary directory and only reuses valid completed caches", () => { diff --git a/apps/desktop/src/wsl/DesktopWslEnvironment.ts b/apps/desktop/src/wsl/DesktopWslEnvironment.ts index b0c9f5ffe44b..d49d95676e64 100644 --- a/apps/desktop/src/wsl/DesktopWslEnvironment.ts +++ b/apps/desktop/src/wsl/DesktopWslEnvironment.ts @@ -147,7 +147,7 @@ const TIMEOUT_RESULT: ShellResult = { transportFailure: "timeout", }; -export const formatWslShellTransportFailureReason = ( +const formatWslShellTransportFailureReason = ( failure: ShellResult["transportFailure"], ): string | null => { switch (failure) { @@ -165,7 +165,7 @@ export const formatWslShellTransportFailureReason = ( // Reuse the SSH remote resolver so WSL and SSH discover version-managed Node // the same way. Passing the engine range lets the resolver fall through to // version managers like nvm when a system node exists but is too old. -export const buildWslNodeEnvPreamble = ( +const buildWslNodeEnvPreamble = ( nodeEngineRange?: string | null, ): string => `${buildRemoteNodeEnvScript({ nodeEngineRange: nodeEngineRange ?? null })} ensure_remote_node_path || true @@ -263,8 +263,7 @@ const WSL_RUNTIME_READY_MARKER = ".t3code-wsl-runtime-ready"; const WSL_RUNTIME_SELECTED_MARKER = ".t3code-wsl-runtime-selected"; const WSL_RUNTIME_SELECTION_GRACE_MINUTES = 5; -export const sanitizeWslRuntimeId = (value: string): string => - value.replace(/[^A-Za-z0-9._-]/g, "_"); +const sanitizeWslRuntimeId = (value: string): string => value.replace(/[^A-Za-z0-9._-]/g, "_"); // `archiveSha256` is the digest the build recorded alongside the archive. The // install verifies the bytes before extracting, so an archive can never be @@ -491,7 +490,7 @@ export const parseWslRuntimeRoot = (stdout: string): string | null => { const NODE_PTY_PREBUILD_MISSING_EXIT_CODE = 4; -export const formatNodePtyProbeFailureReason = (exitCode: number): string | null => +const formatNodePtyProbeFailureReason = (exitCode: number): string | null => exitCode === NODE_PTY_PREBUILD_MISSING_EXIT_CODE ? "WSL support is missing from this T3 Code build: the packaged Linux node-pty binary was not included. Rebuild the Windows artifact with `--wsl-prebuild ` or install a build that includes WSL support." : null; From f93aafcc2c166128070b32259e827f15bc2a1d82 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:30:47 -0700 Subject: [PATCH 184/320] test(web): cover file classification through diff ordering (#10304) --- .../pullRequestFileOrder.logic.test.ts | 51 +++++++++---------- .../pullRequest/pullRequestFileOrder.logic.ts | 2 +- 2 files changed, 25 insertions(+), 28 deletions(-) diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts index 720a5669178f..d3d5d958a49f 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.test.ts @@ -1,7 +1,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; -import { diffFileTier, orderDiffFiles } from "./pullRequestFileOrder.logic"; +import { orderDiffFiles } from "./pullRequestFileOrder.logic"; /** Only the path and the patch's own lines matter here; the viewer fills the rest in. */ function file(name: string, additionLines: ReadonlyArray = []): FileDiffMetadata { @@ -12,34 +12,31 @@ function order(files: ReadonlyArray): Array { return orderDiffFiles(files).map((entry) => entry.name); } -describe("diffFileTier", () => { - it("puts lockfiles, snapshots and build output last", () => { - expect(diffFileTier("pnpm-lock.yaml")).toBe("generated"); - expect(diffFileTier("apps/web/package-lock.json")).toBe("generated"); - expect(diffFileTier("src/__snapshots__/app.ts")).toBe("generated"); - expect(diffFileTier("src/app.test.ts.snap")).toBe("generated"); - expect(diffFileTier("src/api.generated.ts")).toBe("generated"); - expect(diffFileTier("public/app.min.js")).toBe("generated"); - expect(diffFileTier("dist/app.js")).toBe("generated"); - expect(diffFileTier("packages/core/vendor/lib.js")).toBe("generated"); - }); - - it("recognises a test by its name or by the directory holding it", () => { - expect(diffFileTier("src/app.test.ts")).toBe("test"); - expect(diffFileTier("src/app.spec.tsx")).toBe("test"); - expect(diffFileTier("src/__tests__/app.ts")).toBe("test"); - expect(diffFileTier("test/app.ts")).toBe("test"); - expect(diffFileTier("tests/helpers/app.ts")).toBe("test"); - }); - - it("treats everything else as source, including files merely named like a directory", () => { - expect(diffFileTier("src/app.ts")).toBe("source"); - expect(diffFileTier("src/testing.ts")).toBe("source"); - expect(diffFileTier("src/dist.ts")).toBe("source"); +describe("orderDiffFiles", () => { + it("places source before tests and generated files across path conventions", () => { + const source = ["src/app.ts", "src/dist.ts", "src/testing.ts"]; + const tests = [ + "src/__tests__/app.ts", + "src/app.spec.tsx", + "src/app.test.ts", + "test/app.ts", + "tests/helpers/app.ts", + ]; + const generated = [ + "apps/web/package-lock.json", + "dist/app.js", + "packages/core/vendor/lib.js", + "pnpm-lock.yaml", + "public/app.min.js", + "src/__snapshots__/app.ts", + "src/api.generated.ts", + "src/app.test.ts.snap", + ]; + expect( + order([...generated, ...tests, ...source].toReversed().map((path) => file(path))), + ).toEqual([...source, ...tests, ...generated]); }); -}); -describe("orderDiffFiles", () => { it("answers an empty diff with an empty order", () => { expect(order([])).toEqual([]); }); diff --git a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts index b46ed88539c2..1a0df9d9beda 100644 --- a/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestFileOrder.logic.ts @@ -31,7 +31,7 @@ const GENERATED_DIRECTORIES = new Set([ const TEST_DIRECTORIES = new Set(["__tests__", "tests", "test"]); const MODULE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]; -export function diffFileTier(path: string): DiffFileTier { +function diffFileTier(path: string): DiffFileTier { const segments = path.split("/"); const name = segments.at(-1) ?? ""; if ( From a9fc4dc2b010db7979dee9d11c94580fee62b44d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:31:33 -0700 Subject: [PATCH 185/320] test(web): focus command palette tests on search behavior (#10302) --- .../components/CommandPalette.logic.test.ts | 60 ++++++++----------- .../src/components/CommandPalette.logic.ts | 2 - 2 files changed, 26 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 65f183940018..b11d88b764b1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -2,46 +2,15 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import type { Thread } from "../types"; import { - browseInputEndPaddingClass, buildBrowseGroups, buildThreadActionItems, enumerateCommandPaletteItems, filterPinnedBrowseEntries, filterCommandPaletteGroups, - normalizeSearchText, reduceCommandPaletteUiState, type CommandPaletteGroup, } from "./CommandPalette.logic"; -describe("browseInputEndPaddingClass", () => { - it("reserves the widest space for the create action", () => { - expect( - browseInputEndPaddingClass({ - willCreateProjectPath: true, - hasHighlightedBrowseItem: false, - }), - ).toContain("pe-38"); - }); - - it("reserves space for the wider highlighted-item shortcut", () => { - expect( - browseInputEndPaddingClass({ - willCreateProjectPath: false, - hasHighlightedBrowseItem: true, - }), - ).toContain("pe-30"); - }); - - it("keeps the compact reserve for the normal add action", () => { - expect( - browseInputEndPaddingClass({ - willCreateProjectPath: false, - hasHighlightedBrowseItem: false, - }), - ).toContain("pe-24"); - }); -}); - describe("reduceCommandPaletteUiState", () => { const closedState = { open: false, mode: "command", openIntent: null } as const; @@ -333,10 +302,33 @@ describe("buildThreadActionItems", () => { }); it("normalizes case independently of the host locale", () => { - const localeLowerCase = vi.spyOn(String.prototype, "toLocaleLowerCase").mockReturnValue("gıt"); + const toLocaleLowerCase = String.prototype.toLocaleLowerCase; + const localeLowerCase = vi + .spyOn(String.prototype, "toLocaleLowerCase") + .mockImplementation(function (this: string) { + return toLocaleLowerCase.call(this, "tr"); + }); try { - expect(normalizeSearchText("GIT")).toBe("git"); - expect(localeLowerCase).not.toHaveBeenCalled(); + const groups = filterCommandPaletteGroups({ + activeGroups: [], + query: "GIT", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + settingsSearchItems: [ + { + kind: "action", + value: "setting:version-control", + title: "Version control", + searchTerms: ["git"], + icon: null, + run: async () => undefined, + }, + ], + }); + expect(groups.flatMap((group) => group.items.map((item) => item.value))).toEqual([ + "setting:version-control", + ]); } finally { localeLowerCase.mockRestore(); } diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index a0af1be0450b..2492ca0cf986 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -13,8 +13,6 @@ 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"; From fc7ad2edaeedabebcbe1ad6d2f1a4780f0516310 Mon Sep 17 00:00:00 2001 From: Hwanseo Choi Date: Sun, 6 Sep 2026 14:32:00 +0900 Subject: [PATCH 186/320] fix(web): add project settings to legacy sidebar project menu (#10021) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/LegacySidebar.tsx | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 0eb6a74ebe80..1093157710be 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -1725,11 +1725,20 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec }; }; + actionHandlers.set("project-settings", () => { + if (isMobile) setOpenMobile(false); + void router.navigate({ + to: "/projects/$projectKey", + params: { projectKey: project.projectKey }, + }); + }); + const clicked = await api.contextMenu.show( [ buildTargetedItem("rename", "Rename"), buildTargetedItem("grouping", "Group into..."), buildTargetedItem("copy-path", "Copy Path"), + { id: "project-settings", label: "Project settings", icon: "settings" }, buildTargetedItem("delete", "Remove", { destructive: true, }), @@ -1750,10 +1759,14 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec [ copyPathToClipboard, handleRemoveProject, + isMobile, openProjectGroupingDialog, openProjectRenameDialog, project.groupedProjectCount, project.memberProjects, + project.projectKey, + router, + setOpenMobile, suppressProjectClickForContextMenuRef, ], ); From b972f1c1dfc827c2fb074af92f52a9f8fc1fcda5 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:32:35 -0700 Subject: [PATCH 187/320] test(web): keep Markdown gutter styling private (#10306) --- apps/web/src/components/ChatMarkdown.test.tsx | 42 ------------------- apps/web/src/components/ChatMarkdown.tsx | 2 +- 2 files changed, 1 insertion(+), 43 deletions(-) diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index f4551a93088e..3243bf3c2788 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -60,7 +60,6 @@ vi.mock("~/lib/openPullRequestLink", () => ({ import ChatMarkdown, { canUseMarkdownFileShellActions, hasMarkdownFilePrimaryAction, - orderedListGutterStyle, shouldUseMarkdownFileBrowserPrimaryAction, } from "./ChatMarkdown"; @@ -640,47 +639,6 @@ describe("shouldUseMarkdownFileBrowserPrimaryAction", () => { }); }); -describe("orderedListGutterStyle", () => { - it("leaves the default gutter alone for single-digit lists", () => { - expect(orderedListGutterStyle(9, undefined)).toBeUndefined(); - }); - - it("widens the gutter for two-digit lists", () => { - expect(orderedListGutterStyle(99, undefined)).toEqual({ "--list-gutter": "3ch" }); - }); - - it("widens the gutter for a two-digit list that starts above 1", () => { - // start=50 + 49 items => last marker is "98", still two digits. - expect(orderedListGutterStyle(49, 50)).toEqual({ "--list-gutter": "3ch" }); - }); - - it("widens the gutter once the last marker reaches three digits", () => { - // item 100 is the bug from #6512: a 100-item list starting at 1. - expect(orderedListGutterStyle(100, undefined)).toEqual({ "--list-gutter": "4ch" }); - }); - - it("accounts for a non-default start attribute", () => { - // start=95 + 9 items => last marker is "103", three digits. - expect(orderedListGutterStyle(9, 95)).toEqual({ "--list-gutter": "4ch" }); - expect(orderedListGutterStyle(5, "999995")).toEqual({ "--list-gutter": "7ch" }); - }); - - it("scales further for four-digit markers", () => { - expect(orderedListGutterStyle(1000, undefined)).toEqual({ "--list-gutter": "5ch" }); - }); - - it("uses the widest marker and includes a negative start's minus sign", () => { - expect(orderedListGutterStyle(1001, -1000)).toEqual({ "--list-gutter": "6ch" }); - expect(orderedListGutterStyle(3, -15)).toEqual({ "--list-gutter": "4ch" }); - expect(orderedListGutterStyle(3, -5)).toEqual({ "--list-gutter": "3ch" }); - }); - - it("treats a missing/zero item count as a single item", () => { - expect(orderedListGutterStyle(0, undefined)).toBeUndefined(); - expect(orderedListGutterStyle(0, 100)).toEqual({ "--list-gutter": "4ch" }); - }); -}); - describe("ChatMarkdown Windows file links", () => { const environmentId = EnvironmentId.make("env-windows"); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 4e592c406b1d..89d886ec7d1b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -340,7 +340,7 @@ function findTaskListMarkerOffset(markdown: string, listItemStart: number): numb * message's overflow. Widen the gutter to fit the widest marker, including a * negative marker's minus sign. */ -export function orderedListGutterStyle( +function orderedListGutterStyle( itemCount: number, start: unknown, ): { "--list-gutter": string } | undefined { From 9f40b2f563c662b43887b11ff99c466fe871c1af Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:32:48 -0300 Subject: [PATCH 188/320] feat(settings): add shared project defaults and scoped overrides (#9754) Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com> --- .../features/threads/ThreadRouteScreen.tsx | 21 +- .../threads/new-task-flow-provider.tsx | 4 +- .../project/ProjectSetupScriptRunner.test.ts | 61 + .../src/project/ProjectSetupScriptRunner.ts | 22 +- .../provider/Layers/ProviderService.test.ts | 82 +- .../src/provider/Layers/ProviderService.ts | 22 +- apps/server/src/server.ts | 6 +- apps/server/src/serverRuntimeStartup.test.ts | 64 +- apps/server/src/serverRuntimeStartup.ts | 21 +- apps/server/src/vcs/VcsStatusBroadcaster.ts | 17 +- apps/web/src/components/ChatView.tsx | 89 +- apps/web/src/components/CommandPalette.tsx | 2 + .../src/components/chat/DraftHeroHeadline.tsx | 9 +- .../DesktopAppActivationCoordinator.tsx | 7 +- .../components/onboarding/WelcomeWizard.tsx | 7 +- .../settings/IntegrationsSettings.test.tsx | 14 +- .../settings/IntegrationsSettings.tsx | 40 +- .../settings/ProjectActionsList.tsx | 69 ++ .../ProjectDefaultActionsSettings.tsx | 114 ++ .../settings/ProjectDefaultsSettings.tsx | 474 ++++++++ .../settings/ProjectSettingsPanel.tsx | 1058 ++++++++++------- .../components/settings/ProjectsSettings.tsx | 160 +++ .../components/settings/SettingsPanels.tsx | 51 +- .../settings/SettingsSidebarNav.tsx | 10 +- .../components/settings/settingsLayout.tsx | 10 +- .../src/components/settings/settingsSearch.ts | 15 +- apps/web/src/hooks/useHandleNewThread.test.ts | 26 +- apps/web/src/hooks/useHandleNewThread.ts | 30 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/projects.$projectKey.tsx | 10 +- apps/web/src/routes/settings.projects.tsx | 27 + docs/user/project-settings.md | 21 +- .../src/state/sharedSettings.test.ts | 16 +- .../src/state/sharedSettings.ts | 1 - packages/contracts/src/settings.ts | 37 +- packages/shared/src/projectScripts.ts | 22 +- packages/shared/src/serverSettings.test.ts | 168 +++ packages/shared/src/serverSettings.ts | 54 + 38 files changed, 2236 insertions(+), 646 deletions(-) create mode 100644 apps/web/src/components/settings/ProjectActionsList.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectDefaultsSettings.tsx create mode 100644 apps/web/src/components/settings/ProjectsSettings.tsx create mode 100644 apps/web/src/routes/settings.projects.tsx diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index df9486e8556a..f63a206e7ef7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -7,12 +7,21 @@ import { } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + EnvironmentId, + ThreadId, + type ProjectScript, +} from "@t3tools/contracts"; import { requestOlderThreadTurns, threadHasOlderTurns, } from "@t3tools/client-runtime/state/threads"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; @@ -627,7 +636,12 @@ function ThreadRouteContent( gitOperationLabel: gitState.gitOperationLabel, canOpenTerminal: Boolean(selectedThreadProject?.workspaceRoot), canOpenFiles: Boolean(selectedThreadProject?.workspaceRoot), - projectScripts: selectedThreadProject?.scripts ?? [], + projectScripts: selectedThreadProject + ? resolveProjectScripts( + routeEnvironmentRuntime?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + selectedThreadProject, + ) + : [], terminalSessions: terminalMenuSessions, showDirectFileControl: layout.usesSplitView, onOpenTerminal: handleOpenTerminal, @@ -819,6 +833,7 @@ function ThreadRouteContent( <> {activeInspectorRenderer ? : null} , + settings = ServerSettings.layerTest(), ) => ProjectSetupScriptRunner.layer.pipe( Layer.provideMerge(makeProjectionSnapshotQueryLayer(project)), Layer.provideMerge(makeTerminalManagerLayer(terminal)), + Layer.provide(settings), ); describe("ProjectSetupScriptRunner", () => { + it.effect("runs the inherited machine setup action in the checkout's worktree", () => { + const open = vi.fn(() => + Effect.succeed({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + status: "running" as const, + pid: 123, + history: "", + exitCode: null, + exitSignal: null, + label: "setup-default-setup", + updatedAt: "2026-01-01T00:00:00.000Z", + }), + ); + const write = vi.fn(() => Effect.void); + return Effect.gen(function* () { + const runner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const result = yield* runner.runForThread({ + threadId: "thread-1", + projectId: "project-1", + worktreePath: "/repo/worktrees/a", + }); + expect(result).toMatchObject({ status: "started", scriptId: "default-setup" }); + expect(open).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + cwd: "/repo/worktrees/a", + worktreePath: "/repo/worktrees/a", + env: { T3CODE_PROJECT_ROOT: "/repo/project", T3CODE_WORKTREE_PATH: "/repo/worktrees/a" }, + }); + expect(write).toHaveBeenCalledWith({ + threadId: "thread-1", + terminalId: "setup-default-setup", + data: "npm install\r", + }); + }).pipe( + Effect.provide( + testLayer( + makeProject([]), + { open, write }, + ServerSettings.layerTest({ + defaultProjectScripts: [ + { + id: "default-setup", + name: "Setup", + command: "npm install", + icon: "configure", + runOnWorktreeCreate: true, + }, + ], + }), + ), + ), + ); + }); + it.effect("returns no-script when no setup script exists", () => { const open = vi.fn(() => Effect.die("unexpected open")); const write = vi.fn(() => Effect.die("unexpected write")); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 41bf0fabf489..6a79c853dc99 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -1,5 +1,9 @@ import { ProjectId } from "@t3tools/contracts"; -import { projectScriptRuntimeEnv, setupProjectScript } from "@t3tools/shared/projectScripts"; +import { + projectScriptRuntimeEnv, + resolveProjectScripts, + setupProjectScript, +} from "@t3tools/shared/projectScripts"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -7,6 +11,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; export interface ProjectSetupScriptRunnerResultNoScript { @@ -40,7 +45,7 @@ export class ProjectSetupScriptOperationError extends Schema.TaggedErrorClass + new ProjectSetupScriptOperationError({ + ...errorContext, + operation: "readSettings", + cause, + }), + ), + ); + const script = setupProjectScript(resolveProjectScripts(settings, project)); if (!script) { return { status: "no-script", diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 238265ec5a45..fecd7fca9096 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -19,6 +19,8 @@ import { EnvironmentId, EventId, MessageId, + OrchestrationThreadShell, + ProjectId, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderDriverKind, ProviderInstanceId, @@ -75,6 +77,7 @@ import * as ServerConfig from "../../config.ts"; import * as ServerSettings from "../../serverSettings.ts"; import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import { makeAdapterRegistryMock } from "../testUtils/providerAdapterRegistryMock.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const defaultServerSettingsLayer = ServerSettings.ServerSettingsService.layerTest(); const serverConfigTestLayer = ServerConfig.layerTest(process.cwd(), process.cwd()).pipe( @@ -4302,10 +4305,17 @@ boundedListing.layer("ProviderServiceLive session listing", (it) => { ); }); +const decodeBrowserAccessThreadShell = Schema.decodeUnknownEffect(OrchestrationThreadShell); + describe("agent browser access", () => { const revokedThreads: Array = []; + const projectId = ProjectId.make("project-browser-access"); - const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) => + const startSessionWith = ( + enableAgentBrowserAccess: boolean, + threadId: ThreadId, + projectOverride?: boolean, + ) => Effect.gen(function* () { const issued: Array = []; const codex = makeFakeCodexAdapter(); @@ -4319,6 +4329,49 @@ describe("agent browser access", () => { const directoryLayer = ProviderSessionDirectoryLive.pipe( Layer.provide(runtimeRepositoryLayer), ); + const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getImportedAgentSessionSources: () => Effect.die("unused"), + getUserInputActivity: () => Effect.die("unused"), + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getEventReplayStats: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: () => Effect.die("unused"), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadRuntimeContext: () => Effect.die("unused"), + getThreadShellById: (requestedThreadId) => + Effect.gen(function* () { + assert.equal(requestedThreadId, threadId); + return Option.some( + yield* decodeBrowserAccessThreadShell({ + id: threadId, + projectId, + title: "Browser access test", + modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), + runtimeMode: "full-access", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + }), + ); + }).pipe(Effect.orDie), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); const providerLayer = makeProviderServiceLive({ issueMcpCredential: (request) => Effect.sync(() => { @@ -4329,7 +4382,14 @@ describe("agent browser access", () => { }).pipe( Layer.provide(providerAdapterLayer), Layer.provide(directoryLayer), - Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })), + Layer.provide(projectionLayer), + Layer.provide( + ServerSettings.ServerSettingsService.layerTest({ + enableAgentBrowserAccess, + projectAgentBrowserAccessOverrides: + projectOverride === undefined ? {} : { [projectId]: projectOverride }, + }), + ), Layer.provide(serverConfigTestLayer), Layer.provide(AnalyticsService.layerTest), Layer.provide( @@ -4387,4 +4447,22 @@ describe("agent browser access", () => { assert.deepEqual(issued, [threadId]); }).pipe(Effect.provide(NodeServices.layer)), ); + + it.effect("withholds and revokes MCP credentials when the project disables browser access", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-off"); + revokedThreads.length = 0; + const issued = yield* startSessionWith(true, threadId, false); + assert.deepEqual(issued, []); + assert.deepEqual(revokedThreads, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("requests an MCP credential when the project overrides browser access to on", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-project-browser-on"); + const issued = yield* startSessionWith(false, threadId, true); + assert.deepEqual(issued, [threadId]); + }).pipe(Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index b853d779763c..d9cac46ec4d9 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -32,6 +32,7 @@ import { import { expandAssistantCitationsForProvider } from "@t3tools/shared/assistantCitations"; import { causeErrorTag } from "@t3tools/shared/observability"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; +import { resolveProjectAgentBrowserAccess } from "@t3tools/shared/serverSettings"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; @@ -72,6 +73,7 @@ import * as AnalyticsService from "../../telemetry/AnalyticsService.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; import * as ServerSettings from "../../serverSettings.ts"; +import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); interface PendingCompaction { @@ -323,6 +325,9 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry; const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionQuery = yield* Effect.serviceOption( + ProjectionSnapshotQuery.ProjectionSnapshotQuery, + ); const issueMcpCredential = options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential; const revokeMcpCredential = @@ -714,8 +719,19 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( * "off" silently becoming "on" would violate the user's stated choice, * whereas the reverse costs an agent one toolset and is visible immediately. */ - const agentBrowserAccessEnabled = serverSettings.getSettings.pipe( - Effect.map((settings) => settings.enableAgentBrowserAccess), + const agentBrowserAccessEnabled = Effect.fn("ProviderService.agentBrowserAccessEnabled")( + function* (threadId: ThreadId) { + const settings = yield* serverSettings.getSettings; + if (Object.keys(settings.projectAgentBrowserAccessOverrides).length === 0) { + return settings.enableAgentBrowserAccess; + } + // Provider-only runtimes may omit orchestration. An unresolved project + // must not bypass an explicit browser override. + if (Option.isNone(projectionQuery)) return false; + const thread = yield* projectionQuery.value.getThreadShellById(threadId); + if (Option.isNone(thread)) return false; + return resolveProjectAgentBrowserAccess(settings, thread.value.projectId); + }, Effect.catch((cause) => Effect.logWarning( "Could not read server settings; withholding agent browser access for this session.", @@ -726,7 +742,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => Effect.gen(function* () { - if (!(yield* agentBrowserAccessEnabled)) { + if (!(yield* agentBrowserAccessEnabled(threadId))) { // Revoke as well as clear. Every other prepare path reaches // `issueActiveMcpCredential`, which revokes the thread first, so // skipping it here would leave a previously issued bearer token valid diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index c0be0c444573..4abe43d8a631 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -317,7 +317,7 @@ const PullRequestServiceLive = PullRequestService.layer.pipe( ); const GitManagerLayerLive = GitManager.layer.pipe( - Layer.provideMerge(ProjectSetupScriptRunner.layer), + Layer.provideMerge(ProjectSetupScriptRunner.layer.pipe(Layer.provide(ServerSettingsLayerLive))), Layer.provideMerge(GitVcsDriver.layer), Layer.provideMerge(SourceControlProviderRegistryLayerLive), Layer.provideMerge(TextGeneration.layer), @@ -353,7 +353,9 @@ const VcsLayerLive = Layer.empty.pipe( Layer.provideMerge( VcsStatusBroadcaster.layer.pipe( Layer.provide(GitWorkflowLayerLive), - Layer.provide(VcsStatusBroadcaster.autoPullPolicyLayer), + Layer.provide( + VcsStatusBroadcaster.autoPullPolicyLayer.pipe(Layer.provide(ServerSettingsLayerLive)), + ), ), ), ); diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 8cba52552270..88e3c2e88588 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -14,6 +14,7 @@ import * as ServerConfig from "./config.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; +import * as ServerSettings from "./serverSettings.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; it.effect("automatic pull only updates enabled, behind, clean default-branch checkouts", () => @@ -40,7 +41,7 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che }), } as unknown as GitVcsDriver.GitVcsDriver["Service"]; const project = (workspaceRoot: string, autoPull = true) => - ({ workspaceRoot, autoPull }) as never; + ({ id: ProjectId.make(workspaceRoot), workspaceRoot, autoPull }) as never; yield* ServerRuntimeStartup.autoPullProjects([ project("/clean"), @@ -52,6 +53,16 @@ it.effect("automatic pull only updates enabled, behind, clean default-branch che ]).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); assert.deepStrictEqual(pulled, ["/clean"]); + + pulled.length = 0; + yield* ServerRuntimeStartup.autoPullProjects( + [project("/inherited", false), project("/opted-out"), project("/dirty", false)], + { + defaultAutoPull: true, + projectAutoPullOverrides: { [ProjectId.make("/opted-out")]: false }, + }, + ).pipe(Effect.provideService(GitVcsDriver.GitVcsDriver, git)); + assert.deepStrictEqual(pulled, ["/inherited"]); }), ); @@ -124,6 +135,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa return Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -189,8 +201,19 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa }); }); -it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when missing", () => +it.effect.each([ + { existing: false, machineModel: null, projectModel: null }, + { existing: false, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: null }, + { existing: true, machineModel: "claude-sonnet-4-6", projectModel: "gpt-5.4" }, +])("auto-bootstrap model precedence: %j", ({ existing, machineModel, projectModel }) => Effect.gen(function* () { + const machineSelection = machineModel + ? { instanceId: ProviderInstanceId.make("claude-code"), model: machineModel } + : null; + const projectSelection = projectModel + ? { instanceId: ProviderInstanceId.make("codex"), model: projectModel } + : null; const dispatchCalls = yield* Ref.make< ReadonlyArray<{ readonly type: string; @@ -199,6 +222,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when }> >([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest({ defaultModelSelection: machineSelection })), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -212,7 +236,21 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when getSnapshotSequence: () => Effect.die("unused"), getCounts: () => Effect.die("unused"), getEventReplayStats: () => Effect.die("unused"), - getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()), + getActiveProjectByWorkspaceRoot: () => + Effect.succeed( + existing + ? Option.some({ + id: ProjectId.make("existing-project"), + title: "Startup Project", + workspaceRoot: "/tmp/startup-project", + defaultModelSelection: projectSelection, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, + }) + : Option.none(), + ), getProjectShellById: () => Effect.die("unused"), getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()), getImportedAgentSessionSources: () => Effect.die("unused"), @@ -241,18 +279,22 @@ it.effect("resolveAutoBootstrapWelcomeTargets creates a project and thread when assert.equal(typeof targets.bootstrapProjectId, "string"); assert.equal(typeof targets.bootstrapThreadId, "string"); - assert.equal(targets.bootstrapProjectCreated, true); + assert.equal(targets.bootstrapProjectCreated, !existing); assert.equal(targets.bootstrapThreadCreated, true); const commands = yield* Ref.get(dispatchCalls); assert.deepStrictEqual( commands.map((command) => command.type), - ["project.create", "thread.create"], + existing ? ["thread.create"] : ["project.create", "thread.create"], + ); + if (!existing) assert.equal("defaultModelSelection" in commands[0]!, false); + assert.deepStrictEqual( + commands.at(-1)?.modelSelection, + projectSelection ?? + machineSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }, ); - assert.equal("defaultModelSelection" in commands[0]!, false); - assert.deepStrictEqual(commands[1]?.modelSelection, { - instanceId: ProviderInstanceId.make("codex"), - model: DEFAULT_MODEL, - }); }), ); @@ -262,6 +304,7 @@ it.effect( Effect.gen(function* () { const dispatchCalls = yield* Ref.make>([]); const targets = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, @@ -322,6 +365,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa const dispatchCalls = yield* Ref.make>([]); const error = yield* ServerRuntimeStartup.resolveAutoBootstrapWelcomeTargets.pipe( + Effect.provide(ServerSettings.layerTest()), Effect.provideService(ServerConfig.ServerConfig, { cwd: "/tmp/startup-project", autoBootstrapProjectFromCwd: true, diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 2a12dbb3637c..6f8bbc053b0c 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -2,6 +2,7 @@ import { CommandId, DEFAULT_MODEL, DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_SERVER_SETTINGS, type ModelSelection, type OrchestrationProjectShell, ProjectId, @@ -9,6 +10,7 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as Cause from "effect/Cause"; import * as Console from "effect/Console"; import * as Context from "effect/Context"; @@ -198,6 +200,9 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { let bootstrapThreadCreated = false; if (serverConfig.autoBootstrapProjectFromCwd) { + const settings = yield* (yield* ServerSettings.ServerSettingsService).getSettings; + const defaultModelSelection = + settings.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); yield* Effect.gen(function* () { const existingProject = yield* projectionReadModelQuery.getActiveProjectByWorkspaceRoot( serverConfig.cwd, @@ -209,7 +214,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { const createdAt = DateTime.formatIso(yield* DateTime.now); nextProjectId = ProjectId.make(yield* randomUUID); const bootstrapProjectTitle = path.basename(serverConfig.cwd) || "project"; - nextThreadModelSelection = getAutoBootstrapThreadModelSelection(); + nextThreadModelSelection = defaultModelSelection; yield* orchestrationEngine.dispatch({ type: "project.create", commandId: CommandId.make(yield* randomUUID), @@ -224,7 +229,7 @@ export const resolveAutoBootstrapWelcomeTargets = Effect.gen(function* () { nextProjectId = existingProject.value.id; bootstrapProjectId = nextProjectId; nextThreadModelSelection = - existingProject.value.defaultModelSelection ?? getAutoBootstrapThreadModelSelection(); + existingProject.value.defaultModelSelection ?? defaultModelSelection; } yield* Effect.gen(function* () { @@ -737,12 +742,16 @@ interface StartupOptions { export const autoPullProjects = Effect.fn("autoPullProjects")(function* ( projects: ReadonlyArray, + settings: Pick< + typeof DEFAULT_SERVER_SETTINGS, + "defaultAutoPull" | "projectAutoPullOverrides" + > = DEFAULT_SERVER_SETTINGS, ) { const git = yield* GitVcsDriver.GitVcsDriver; const workspaceRoots = [ ...new Set( projects - .filter((project) => project.autoPull === true) + .filter((project) => resolveProjectAutoPull(settings, project.id, project.autoPull)) .map((project) => project.workspaceRoot), ), ]; @@ -813,7 +822,11 @@ export const make = (options?: StartupOptions) => const reactorScope = yield* Scope.make("sequential"); const syncAutoPullProjects = projectionSnapshotQuery.getShellSnapshot().pipe( - Effect.flatMap((snapshot) => autoPullProjects(snapshot.projects)), + Effect.flatMap((snapshot) => + serverSettings.getSettings.pipe( + Effect.flatMap((settings) => autoPullProjects(snapshot.projects, settings)), + ), + ), Effect.catch((cause) => Effect.logWarning("Failed to load projects for automatic pull", { cause }), ), diff --git a/apps/server/src/vcs/VcsStatusBroadcaster.ts b/apps/server/src/vcs/VcsStatusBroadcaster.ts index 04d320c03bf9..c00a07f2a7a9 100644 --- a/apps/server/src/vcs/VcsStatusBroadcaster.ts +++ b/apps/server/src/vcs/VcsStatusBroadcaster.ts @@ -22,10 +22,12 @@ import type { VcsStatusStreamEvent, } from "@t3tools/contracts"; import { mergeGitStatusParts } from "@t3tools/shared/git"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import * as BackgroundPolicy from "../background/BackgroundPolicy.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; const DEFAULT_VCS_STATUS_REFRESH_INTERVAL = Duration.seconds(30); const VCS_STATUS_REFRESH_FAILURE_BASE_DELAY = Duration.seconds(30); @@ -151,12 +153,17 @@ export const autoPullPolicyLayer = Layer.effect( VcsAutoPullPolicy, Effect.gen(function* () { const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const serverSettings = yield* ServerSettings.ServerSettingsService; return { - isEnabled: (cwd: string) => - snapshots.getActiveProjectByWorkspaceRoot(cwd).pipe( - Effect.map((project) => project._tag === "Some" && project.value.autoPull === true), - Effect.orElseSucceed(() => false), - ), + isEnabled: Effect.fn("VcsAutoPullPolicy.isEnabled")( + function* (cwd: string) { + const project = yield* snapshots.getActiveProjectByWorkspaceRoot(cwd); + if (project._tag === "None") return false; + const settings = yield* serverSettings.getSettings; + return resolveProjectAutoPull(settings, project.value.id, project.value.autoPull); + }, + Effect.orElseSucceed(() => false), + ), }; }), ); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c9c2c60badb5..d9d66df9cd51 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -53,7 +53,11 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import { resolveThreadReferenceCopyTarget } from "@t3tools/shared/threadReference"; import { @@ -276,7 +280,6 @@ import { appendReviewCommentsToPrompt, type ReviewCommentContext } from "../revi import { environmentCatalog } from "../connection/catalog"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useKnownTerminalSessions, useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { environmentServerConfigsAtom, @@ -1386,7 +1389,9 @@ export default function ChatView(props: ChatViewProps) { [environmentId, threadId], ); const routeThreadKey = useMemo(() => scopedThreadKey(routeThreadRef), [routeThreadRef]); - const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); + const updateProjectScriptSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); @@ -1474,9 +1479,6 @@ export default function ChatView(props: ChatViewProps) { }, [routeKind, routeThreadRef, routeThreadState]); const markThreadVisited = useUiStateStore((store) => store.markThreadVisited); const settings = useEnvironmentSettings(environmentId); - // New-thread defaults live in the primary environment's settings.json (the - // settings UI never writes to remote environments), so read them from the - // primary server rather than the thread's environment. const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const setStickyComposerModelSelection = useComposerDraftStore( (store) => store.setStickyModelSelection, @@ -1758,10 +1760,17 @@ export default function ChatView(props: ChatViewProps) { ? buildLocalDraftThread( threadId, draftThread, - fallbackDraftProject?.defaultModelSelection ?? NO_PROVIDER_MODEL_SELECTION, + fallbackDraftProject?.defaultModelSelection ?? + settings.defaultModelSelection ?? + NO_PROVIDER_MODEL_SELECTION, ) : undefined, - [draftThread, fallbackDraftProject?.defaultModelSelection, threadId], + [ + draftThread, + fallbackDraftProject?.defaultModelSelection, + settings.defaultModelSelection, + threadId, + ], ); // Promotion is data-driven: the draft route keeps rendering while the // server thread (same pre-allocated ref) starts, so live state must not @@ -1987,6 +1996,12 @@ export default function ChatView(props: ChatViewProps) { [activeThread?.environmentId, activeThread?.projectId], ); const activeProject = useProject(activeProjectRef); + const activeProjectScripts = useMemo( + () => (activeProject ? resolveProjectScripts(settings, activeProject) : []), + [activeProject, settings], + ); + const activeProjectDefaultModelSelection = + activeProject?.defaultModelSelection ?? settings.defaultModelSelection; const handleNewThreadInActiveProject = useCallback(() => { startNewThreadForProject(activeProjectRef, handleNewThread); }, [activeProjectRef, handleNewThread]); @@ -2035,8 +2050,8 @@ export default function ChatView(props: ChatViewProps) { [activeProjectKey], ); const configuredPreviewUrls = useMemo( - () => getConfiguredPreviewUrls(activeProject?.scripts), - [activeProject?.scripts], + () => getConfiguredPreviewUrls(activeProjectScripts), + [activeProjectScripts], ); useEffect(() => { @@ -2280,7 +2295,7 @@ export default function ChatView(props: ChatViewProps) { const selectedProviderByThreadId = composerActiveProvider ?? null; const threadProvider = activeThread?.modelSelection.instanceId ?? - activeProject?.defaultModelSelection?.instanceId ?? + activeProjectDefaultModelSelection?.instanceId ?? null; const lockedProvider = deriveLockedProvider({ thread: activeThread, @@ -2514,14 +2529,14 @@ export default function ChatView(props: ChatViewProps) { selectedProviderByThreadId, activeThread?.session?.providerInstanceId, activeThread?.modelSelection.instanceId, - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, ], lockedProvider, lockedInstanceId: activeThread?.session?.providerInstanceId ?? activeThread?.modelSelection.instanceId, }), [ - activeProject?.defaultModelSelection?.instanceId, + activeProjectDefaultModelSelection?.instanceId, activeThread?.modelSelection.instanceId, activeThread?.session?.providerInstanceId, lockedProvider, @@ -3652,11 +3667,14 @@ export default function ChatView(props: ChatViewProps) { keybindingCommand: KeybindingCommand; }): Promise> => { const updateResult = mapAtomCommandResult( - await updateProject({ + await updateProjectScriptSettings({ environmentId, input: { - projectId: input.projectId, - scripts: input.nextScripts, + patch: { + projectScriptOverrides: { + [input.projectId]: input.nextScripts, + }, + }, }, }), () => undefined, @@ -3681,7 +3699,7 @@ export default function ChatView(props: ChatViewProps) { } return updateResult; }, - [environmentId, updateProject, upsertKeybinding], + [environmentId, updateProjectScriptSettings, upsertKeybinding], ); const saveProjectScript = useCallback( async (input: NewProjectScriptInput): Promise> => { @@ -3690,28 +3708,28 @@ export default function ChatView(props: ChatViewProps) { } const nextId = nextProjectScriptId( input.name, - activeProject.scripts.map((script) => script.id), + activeProjectScripts.map((script) => script.id), ); const nextScript = buildProjectScript(nextId, input); const nextScripts = input.runOnWorktreeCreate ? [ - ...activeProject.scripts.map((script) => + ...activeProjectScripts.map((script) => script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ), nextScript, ] - : [...activeProject.scripts, nextScript]; + : [...activeProjectScripts, nextScript]; return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(nextId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const updateProjectScript = useCallback( async ( @@ -3721,13 +3739,13 @@ export default function ChatView(props: ChatViewProps) { if (!activeProject) { return AsyncResult.success(undefined); } - const existingScript = activeProject.scripts.find((script) => script.id === scriptId); + const existingScript = activeProjectScripts.find((script) => script.id === scriptId); if (!existingScript) { return AsyncResult.failure(Cause.fail(new Error("Script not found."))); } const updatedScript = buildProjectScript(existingScript.id, input); - const nextScripts = activeProject.scripts.map((script) => + const nextScripts = activeProjectScripts.map((script) => script.id === scriptId ? updatedScript : input.runOnWorktreeCreate @@ -3738,27 +3756,27 @@ export default function ChatView(props: ChatViewProps) { return persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: input.keybinding, keybindingCommand: commandForProjectScript(scriptId), }); }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const deleteProjectScript = useCallback( async (scriptId: string): Promise> => { if (!activeProject) { return AsyncResult.success(undefined); } - const nextScripts = activeProject.scripts.filter((script) => script.id !== scriptId); + const nextScripts = activeProjectScripts.filter((script) => script.id !== scriptId); - const deletedName = activeProject.scripts.find((s) => s.id === scriptId)?.name; + const deletedName = activeProjectScripts.find((s) => s.id === scriptId)?.name; const result = await persistProjectScripts({ projectId: activeProject.id, projectCwd: activeProject.workspaceRoot, - previousScripts: activeProject.scripts, + previousScripts: activeProjectScripts, nextScripts, keybinding: null, keybindingCommand: commandForProjectScript(scriptId), @@ -3780,7 +3798,7 @@ export default function ChatView(props: ChatViewProps) { } return result; }, - [activeProject, persistProjectScripts], + [activeProject, activeProjectScripts, persistProjectScripts], ); const handleRuntimeModeChange = useCallback( @@ -6061,7 +6079,7 @@ export default function ChatView(props: ChatViewProps) { const scriptId = projectScriptIdFromCommand(command); if (!scriptId || !activeProject) return; - const script = activeProject.scripts.find((entry) => entry.id === scriptId); + const script = activeProjectScripts.find((entry) => entry.id === scriptId); if (!script) return; event.preventDefault(); event.stopPropagation(); @@ -6072,6 +6090,7 @@ export default function ChatView(props: ChatViewProps) { }, [ activeProject, activeRightPanelSurface, + activeProjectScripts, addTerminalSurface, activeThreadRef, activeThreadPinned, @@ -6747,7 +6766,7 @@ export default function ChatView(props: ChatViewProps) { const title = truncate(titleSeed); const threadCreateModelSelection = createModelSelection( ctxSelectedModelSelection.instanceId, - ctxSelectedModel || activeProject.defaultModelSelection?.model || DEFAULT_MODEL, + ctxSelectedModel || activeProjectDefaultModelSelection?.model || DEFAULT_MODEL, ctxSelectedModelSelection.options, ); @@ -7885,7 +7904,7 @@ export default function ChatView(props: ChatViewProps) { activeProjectFaviconPath={activeProject?.faviconPath ?? null} activeProjectIcon={activeProject?.projectIcon ?? null} openInCwd={gitCwd} - activeProjectScripts={activeProject?.scripts} + activeProjectScripts={activeProjectScripts} preferredScriptId={ activeProject ? (lastInvokedScriptByProjectId[activeProject.id] ?? null) : null } @@ -8124,9 +8143,7 @@ export default function ChatView(props: ChatViewProps) { interactionMode={interactionMode} lockedProvider={lockedProvider} providerStatuses={providerStatuses as ServerProvider[]} - activeProjectDefaultModelSelection={ - activeProject?.defaultModelSelection - } + activeProjectDefaultModelSelection={activeProjectDefaultModelSelection} activeThreadModelSelection={activeThread?.modelSelection} activeContextWindow={activeContextWindow} compactThreadUnavailable={compactThreadUnavailable} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index b0dd3febebcb..e54671f8e2fa 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -1791,6 +1791,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 04bbeb6ce49b..98f9caa2300a 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -149,8 +149,13 @@ export function DraftHeroHeadline({ ); if (!hasExplicitComposerModelSelection(currentDraft)) { applyStickyState(draftId); - if (project.defaultModelSelection) { - setModelSelection(draftId, project.defaultModelSelection, { + const defaultModelSelection = + project.defaultModelSelection ?? + environments.find( + (environment) => environment.environmentId === project.environmentId, + )?.serverConfig?.settings.defaultModelSelection; + if (defaultModelSelection) { + setModelSelection(draftId, defaultModelSelection, { replaceOptions: true, }); } diff --git a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx index e97a46a2a258..3e941e69dd53 100644 --- a/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx +++ b/apps/web/src/components/desktop/DesktopAppActivationCoordinator.tsx @@ -6,7 +6,6 @@ import { handleDesktopAppActivationRequest } from "../../desktopAppActivation"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; import { findProjectByPath, inferProjectTitleFromPath } from "../../lib/projectPaths"; import { newProjectId } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { readProjects, waitForProject } from "../../state/entities"; import { usePrimaryEnvironment } from "../../state/environments"; import { projectEnvironment } from "../../state/projects"; @@ -52,10 +51,6 @@ export function DesktopAppActivationCoordinator() { ) ?? null, createProject: async (environmentId, workspaceRoot) => { const projectId = newProjectId(); - const providers = - primaryEnvironment?.environmentId === environmentId - ? (primaryEnvironment.serverConfig?.providers ?? []) - : []; const result = await createProject({ environmentId, input: { @@ -63,7 +58,7 @@ export function DesktopAppActivationCoordinator() { title: inferProjectTitleFromPath(workspaceRoot), workspaceRoot, createWorkspaceRootIfMissing: false, - defaultModelSelection: resolveDefaultProviderModelSelection(providers, null), + defaultModelSelection: null, }, }); if (result._tag === "Failure") { diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx index cb31ef259667..62665aa86ebf 100644 --- a/apps/web/src/components/onboarding/WelcomeWizard.tsx +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -51,7 +51,6 @@ import { } from "../../onboarding/targetEnvironment.logic"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { newProjectId, randomUUID } from "../../lib/utils"; -import { resolveDefaultProviderModelSelection } from "../../providerInstances"; import { agentSessionImport, agentSessionScan } from "../../state/agentSessions"; import { readProjects, useProjects } from "../../state/entities"; import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; @@ -1029,9 +1028,6 @@ function ImportStep({ const targetEnvironment = useOnboardingTargetEnvironment(mode, pairedEnvironmentId); const environmentId = targetEnvironment?.environmentId ?? null; const machineLabel = targetEnvironment?.label ?? "this machine"; - const providers = useAtomValue( - serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), - ); const scan = useEnvironmentQuery( environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), ); @@ -1123,7 +1119,6 @@ function ImportStep({ const importGeneration = importGenerationRef.current; const importedProjects = importedProjectsRef.current; const projectAttempts = projectAttemptsRef.current; - const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); // Interrupted imports are neither failures nor successes — the command was // superseded or the environment dropped — but they still didn't land, so // they must not read as "imported everything". Retries skip paths that @@ -1164,7 +1159,7 @@ function ImportStep({ title: candidate.title, workspaceRoot: candidate.path, createWorkspaceRootIfMissing: false, - defaultModelSelection, + defaultModelSelection: null, }, }); if ( diff --git a/apps/web/src/components/settings/IntegrationsSettings.test.tsx b/apps/web/src/components/settings/IntegrationsSettings.test.tsx index 1a8dc7d6aae8..5d185fee5824 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.test.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.test.tsx @@ -1,4 +1,10 @@ import { DEFAULT_CLIENT_SETTINGS, DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts"; +import { + createMemoryHistory, + createRootRoute, + createRouter, + RouterProvider, +} from "@tanstack/react-router"; import { act, StrictMode, type ReactNode } from "react"; import { create, type ReactTestRenderer } from "react-test-renderer"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -44,13 +50,19 @@ afterEach(async () => { }); async function openSettings() { + const router = createRouter({ + routeTree: createRootRoute({ component: IntegrationsSettingsPanel }), + history: createMemoryHistory(), + }); + await router.load(); await act(() => { renderer = create( - + , ); }); + expect(renderer!.root.findByType(IntegrationsSettingsPanel)).toBeDefined(); } describe("Integrations browser discovery", () => { diff --git a/apps/web/src/components/settings/IntegrationsSettings.tsx b/apps/web/src/components/settings/IntegrationsSettings.tsx index e950bab31cbe..514c241a3c57 100644 --- a/apps/web/src/components/settings/IntegrationsSettings.tsx +++ b/apps/web/src/components/settings/IntegrationsSettings.tsx @@ -20,7 +20,6 @@ import { DEFAULT_BROWSER_RECORDING_FRAME_RATE, DEFAULT_BROWSER_VIEWPORT, DEFAULT_PREVIEW_APPEARANCE, - DEFAULT_UNIFIED_SETTINGS, DEFAULT_PREVIEW_ZOOM_FACTOR, FILL_PREVIEW_VIEWPORT, PREVIEW_VIEWPORT_MAX_AREA, @@ -35,6 +34,7 @@ import { type PreviewViewportSetting, } from "@t3tools/contracts"; import { PREVIEW_VIEWPORT_PRESETS } from "@t3tools/shared/previewViewport"; +import { Link } from "@tanstack/react-router"; import { InfoIcon, MoreVertical, Plus as PlusIcon } from "lucide-react"; import { useCallback, useRef, useState, type ReactNode } from "react"; @@ -86,7 +86,6 @@ import { persistClientSettingsUpdate, useClientSettings, useClientSettingsHydrated, - usePrimarySettings, useUpdatePrimarySettings, } from "~/hooks/useSettings"; @@ -552,39 +551,20 @@ function BrowserLinkTargetSetting({ disabled }: { readonly disabled: boolean }) } function AgentBrowserAccessSetting() { - const settings = usePrimarySettings(); - const updateSettings = useUpdatePrimarySettings(); - return ( - updateSettings({ - enableAgentBrowserAccess: DEFAULT_UNIFIED_SETTINGS.enableAgentBrowserAccess, - }) - } - /> - ) : null - } + description="Choose whether agents can use the preview browser for all projects or a specific project." control={ - - updateSettings({ enableAgentBrowserAccess: Boolean(checked) }) + } /> ); diff --git a/apps/web/src/components/settings/ProjectActionsList.tsx b/apps/web/src/components/settings/ProjectActionsList.tsx new file mode 100644 index 000000000000..1794a5fdaa2e --- /dev/null +++ b/apps/web/src/components/settings/ProjectActionsList.tsx @@ -0,0 +1,69 @@ +import type { ProjectScript, ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { SettingsIcon } from "lucide-react"; +import { shortcutLabelForCommand } from "../../keybindings"; +import { commandForProjectScript } from "../../projectScripts"; +import { ScriptIcon } from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { SettingsRow } from "./settingsLayout"; + +export function ProjectActionsList({ + scripts, + keybindings, + disabled, + onEdit, +}: { + scripts: readonly ProjectScript[]; + keybindings: ResolvedKeybindingsConfig; + disabled: boolean; + onEdit: (script: ProjectScript) => void; +}) { + if (scripts.length === 0) + return ( +

+ No actions configured. +

+ ); + return scripts.map((script) => { + const shortcutLabel = shortcutLabelForCommand(keybindings, commandForProjectScript(script.id)); + return ( + + + {script.name} + {script.runOnWorktreeCreate ? ( + + setup + + ) : null} + {script.previewUrl ? ( + + preview · desktop only + + ) : null} + + } + description={{script.command}} + control={ + <> + {shortcutLabel ? ( + {shortcutLabel} + ) : null} + + + } + /> + ); + }); +} diff --git a/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx new file mode 100644 index 000000000000..4385a5901b5e --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultActionsSettings.tsx @@ -0,0 +1,114 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; +import { PlusIcon } from "lucide-react"; +import { useState } from "react"; +import { useEnvironments } from "../../state/environments"; +import { + EMPTY_PROJECT_SCRIPT_INPUT, + editorRequestForScript, + ProjectScriptEditorDialog, + type ProjectScriptEditorRequest, +} from "../projectScriptEditor"; +import { Button } from "../ui/button"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { useProjectScriptSettings } from "./ProjectSettingsPanel"; +import { SettingResetButton, SettingsRow, SettingsSection } from "./settingsLayout"; + +export function ProjectDefaultActionsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const targets = environments.filter( + (environment) => + (environmentId === null || environment.environmentId === environmentId) && + environment.connection.phase === "connected" && + environment.serverConfig !== null, + ); + const representative = targets[0]?.serverConfig; + const scripts = representative?.settings.defaultProjectScripts ?? []; + const keybindings = representative?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; + const mixed = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultProjectScripts) !== + JSON.stringify(scripts), + ); + const [request, setRequest] = useState(null); + const { saving, persist, submit } = useProjectScriptSettings( + targets.flatMap(({ environmentId, serverConfig }) => + serverConfig + ? [ + { + environmentId, + settings: serverConfig.settings, + keybindings: serverConfig.keybindings, + }, + ] + : [], + ), + ); + + return ( + + + Import scripts + + } + /> + (target.serverConfig?.settings.defaultProjectScripts.length ?? 0) > 0, + ) ? ( + void persist(() => [])} + /> + ) : null + } + control={ + + } + /> + {mixed ? ( + + ) : ( + setRequest(editorRequestForScript(script, keybindings))} + /> + )} + + void persist((current) => current.filter((script) => script.id !== id), id, null) + } + onClose={() => setRequest(null)} + /> + + ); +} diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx new file mode 100644 index 000000000000..938000e01002 --- /dev/null +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -0,0 +1,474 @@ +import { + DEFAULT_CLIENT_SETTINGS, + DEFAULT_SERVER_SETTINGS, + type EnvironmentId, + type ModelSelection, + type ProviderInstanceId, + type ServerSettingsPatch, +} from "@t3tools/contracts"; +import { createModelSelection } from "@t3tools/shared/model"; +import { useNavigate } from "@tanstack/react-router"; +import { useRef, useState } from "react"; +import { Trash2Icon } from "lucide-react"; + +import { useClientSettings, useUpdateClientSettings } from "../../hooks/useSettings"; +import { getCustomModelOptionsByInstance } from "../../modelSelection"; +import { + applyProviderInstanceSettings, + deriveProviderInstanceEntries, + resolveDefaultProviderModelSelection, + sortProviderInstanceEntries, +} from "../../providerInstances"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveEnvModeLabel } from "../BranchToolbar.logic"; +import { ProviderModelPicker } from "../chat/ProviderModelPicker"; +import { TraitsPicker } from "../chat/TraitsPicker"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { Switch } from "../ui/switch"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { PROJECT_GROUPING_MODE_LABELS } from "./ProjectSettingsPanel"; +import { ProjectDefaultActionsSettings } from "./ProjectDefaultActionsSettings"; +import { searchableSetting } from "./settingsSearch"; +import { + SETTINGS_PICKER_TRIGGER_CLASSNAME, + SettingResetButton, + SettingsPageContainer, + SettingsRow, + SettingsSection, +} from "./settingsLayout"; + +/** Defaults are written only to the machines selected on the projects settings page. */ +export function ProjectDefaultsSettings({ + environmentId, +}: { + environmentId: EnvironmentId | null; +}) { + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const clientSettings = useClientSettings(); + const updateClientSettings = useUpdateClientSettings(); + const navigate = useNavigate(); + const updateSettings = useAtomCommand( + serverEnvironment.updateSettings, + "project defaults update", + ); + const savingRef = useRef(new Set()); + const [saving, setSaving] = useState>(new Set()); + const scoped = environments.filter( + (environment) => environmentId === null || environment.environmentId === environmentId, + ); + const targets = scoped.filter( + (environment) => + environment.connection.phase === "connected" && environment.serverConfig !== null, + ); + const representative = + targets.find((environment) => environment.environmentId === primaryEnvironmentId) ?? targets[0]; + const serverSettings = representative?.serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; + const providers = representative?.serverConfig?.providers ?? EMPTY_SERVER_PROVIDERS; + const settings = { ...serverSettings, ...clientSettings }; + const storedSelection = serverSettings.defaultModelSelection; + const selection = resolveDefaultProviderModelSelection(providers, storedSelection); + const entries = sortProviderInstanceEntries( + applyProviderInstanceSettings(deriveProviderInstanceEntries(providers), settings), + ); + const modelOptions = getCustomModelOptionsByInstance( + settings, + providers, + selection?.instanceId, + selection?.model, + ); + const activeEntry = entries.find((entry) => entry.instanceId === selection?.instanceId); + const mixedModel = targets.some( + (target) => + JSON.stringify(target.serverConfig?.settings.defaultModelSelection) !== + JSON.stringify(storedSelection), + ); + const mixedWorkspace = targets.some( + (target) => + target.serverConfig?.settings.defaultThreadEnvMode !== serverSettings.defaultThreadEnvMode, + ); + const mixedBrowser = targets.some( + (target) => + target.serverConfig?.settings.enableAgentBrowserAccess !== + serverSettings.enableAgentBrowserAccess, + ); + const disabled = (key: keyof ServerSettingsPatch) => targets.length === 0 || saving.has(key); + const mixedAutoPull = targets.some( + (target) => target.serverConfig?.settings.defaultAutoPull !== serverSettings.defaultAutoPull, + ); + + function modelDisabledReason(instanceId: ProviderInstanceId, model: string): string | null { + const sourceEntry = entries.find((entry) => entry.instanceId === instanceId); + for (const target of targets) { + const config = target.serverConfig; + if (!config) continue; + const entry = applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === instanceId); + const options = getCustomModelOptionsByInstance( + { ...config.settings, ...clientSettings }, + config.providers, + ).get(instanceId); + if ( + !entry?.enabled || + !entry.isAvailable || + entry.driverKind !== sourceEntry?.driverKind || + !options?.some((option) => option.slug === model && !option.isUnavailable) + ) { + return `This model is unavailable on ${target.label}. Select that machine to choose its default separately.`; + } + } + return null; + } + + async function save(patch: ServerSettingsPatch) { + const keys = Object.keys(patch); + if (targets.length === 0 || keys.some((key) => savingRef.current.has(key))) return; + const nextModel = patch.defaultModelSelection; + const reason = nextModel ? modelDisabledReason(nextModel.instanceId, nextModel.model) : null; + if (reason) { + toastManager.add({ type: "error", title: "Default model not saved", description: reason }); + return; + } + for (const key of keys) savingRef.current.add(key); + setSaving(new Set(savingRef.current)); + try { + const results = await Promise.all( + targets.map((target) => + updateSettings({ environmentId: target.environmentId, input: { patch } }), + ), + ); + const failedTargets = targets.filter((_, index) => results[index]?._tag === "Failure"); + if (failedTargets.length > 0) { + toastManager.add({ + type: "error", + title: "Project defaults not saved on every machine", + description: `Could not update ${failedTargets.map((target) => target.label).join(", ")}. Other machines may have saved the change.`, + }); + } + } finally { + for (const key of keys) savingRef.current.delete(key); + setSaving(new Set(savingRef.current)); + } + } + + const setModel = (value: ModelSelection | null) => void save({ defaultModelSelection: value }); + return ( + + + + } + /> + + + +
+ } + /> + {scoped.length > targets.length || targets.length === 0 ? ( +

+ {targets.length === 0 + ? "Connect a machine to change its project defaults." + : "Changes apply to connected machines only. Offline machines keep their current defaults."} +

+ ) : null} + setModel(null)} + /> + ) : null + } + control={ + selection && activeEntry ? ( +
+ { + if (representative) + void navigate({ + to: "/settings/providers", + search: { environmentId: representative.environmentId, instanceId }, + }); + }} + onInstanceModelChange={(instanceId, model) => + setModel(createModelSelection(instanceId, model)) + } + /> + {!mixedModel ? ( + {}} + modelOptions={selection.options ?? []} + allowPromptInjectedEffort={false} + planModeEnabled={settings.planModeEnabled} + triggerVariant="outline" + triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} + onModelOptionsChange={(options) => + setModel(createModelSelection(selection.instanceId, selection.model, options)) + } + /> + ) : null} +
+ ) : ( + No providers available + ) + } + /> + + void save({ defaultThreadEnvMode: DEFAULT_SERVER_SETTINGS.defaultThreadEnvMode }) + } + /> + ) : null + } + control={ + + } + /> + void save({ defaultAutoPull: false })} + /> + ) : null + } + control={ + void save({ defaultAutoPull: enabled })} + /> + } + /> + + void save({ + enableAgentBrowserAccess: DEFAULT_SERVER_SETTINGS.enableAgentBrowserAccess, + }) + } + /> + ) : null + } + control={ + + } + /> + + + + + + + + } + /> + + void updateClientSettings({ + sidebarProjectGroupingMode: DEFAULT_CLIENT_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + + } + /> + + + Remove checkout + + } + /> + + + + + + Remove project + + } + /> + + + ); +} diff --git a/apps/web/src/components/settings/ProjectSettingsPanel.tsx b/apps/web/src/components/settings/ProjectSettingsPanel.tsx index 0181041ec6b1..f4ffe699466e 100644 --- a/apps/web/src/components/settings/ProjectSettingsPanel.tsx +++ b/apps/web/src/components/settings/ProjectSettingsPanel.tsx @@ -12,52 +12,53 @@ import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; -import type { - ContextMenuItem, - ModelSelection, - ProjectIconOverride, - ProviderDriverKind, - SidebarProjectGroupingMode, - T3ProjectFileScript, - ThreadEnvMode, +import { + type EnvironmentId, + type ModelSelection, + type ProjectIconOverride, + type ProjectId, + type ProjectScript, + type ResolvedKeybindingsConfig, + type ServerSettings, + type ProviderDriverKind, + type SidebarProjectGroupingMode, + type T3ProjectFileScript, + type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; +import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; +import { + projectScriptsInheritDefaults, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; -import { useCanGoBack, useNavigate } from "@tanstack/react-router"; +import { useNavigate } from "@tanstack/react-router"; +import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; -import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; -import { - lazy, - Suspense, - useCallback, - useEffect, - useMemo, - useRef, - useState, - type MouseEvent as ReactMouseEvent, -} from "react"; +import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; +import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; -import { isElectron } from "../../env"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, - usePrimarySettings, } from "../../hooks/useSettings"; -import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; -import { shortcutLabelForCommand } from "../../keybindings"; -import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; -import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; -import { readLocalApi } from "../../localApi"; +import { ProjectActionsList } from "./ProjectActionsList"; +import { isElectron } from "../../env"; +import { + decodeProjectScriptKeybindingRule, + keybindingValueForCommand, +} from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; -import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; +import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; +import { readLocalApi } from "../../localApi"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, @@ -98,16 +99,8 @@ import { MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; -import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { - WorkspaceBreadcrumb, - WorkspaceBreadcrumbItem, - WorkspaceBreadcrumbSeparator, -} from "../WorkspaceBreadcrumb"; -import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, @@ -127,14 +120,14 @@ const ProjectIconPickerDialog = lazy(() => })), ); -const PROJECT_GROUPING_MODE_LABELS: Record = { +export const PROJECT_GROUPING_MODE_LABELS: Record = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ -function useSettingsProjectGroups(): SidebarProjectSnapshot[] { +export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); @@ -162,132 +155,59 @@ function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } -export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { - const navigate = useNavigate(); - const canGoBack = useCanGoBack(); - const navigateBackWithinApp = useCallback(() => { - if (canGoBack) { - window.history.back(); - return; - } - void navigate({ to: "/" }); - }, [canGoBack, navigate]); - - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.key !== "Escape") return; - event.preventDefault(); - const activeElement = document.activeElement; - if (activeElement instanceof HTMLElement) { - activeElement.blur(); - } - navigateBackWithinApp(); - }; - window.addEventListener("keydown", onKeyDown); - return () => window.removeEventListener("keydown", onKeyDown); - }, [navigateBackWithinApp]); - - return ( - -
- - - - -
-
- ); -} - -function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { - const groups = useSettingsProjectGroups(); - const navigate = useNavigate(); - const selected = groups.find((group) => group.projectKey === projectKey) ?? null; - const openProjectMenu = (event: ReactMouseEvent) => { - const api = readLocalApi(); - if (!api) return; - - const rect = event.currentTarget.getBoundingClientRect(); - const items: ContextMenuItem[] = groups.map((group) => ({ - id: group.projectKey, - label: group.displayName, - })); - void settlePromise(() => - api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), - ).then((clicked) => { - if (clicked._tag === "Failure" || clicked.value === null) return; - void navigate({ - to: "/projects/$projectKey", - params: { projectKey: clicked.value }, - replace: true, - hashScrollIntoView: false, - }); - }); - }; - - return ( - - Projects - - - {selected ? ( - - ) : ( - Unavailable project - )} - - - ); -} - -function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { +export function ProjectSettingsPanel({ + projectKey, + environmentId = null, +}: { + projectKey: string; + environmentId?: EnvironmentId | null; +}) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; + const members = useMemo( + () => + selected?.memberProjects.filter( + (member) => environmentId === null || member.environmentId === environmentId, + ) ?? [], + [selected, environmentId], + ); // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. - const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); + const lastSelectionRef = useRef<{ + key: string; + environmentId: EnvironmentId | null; + memberKeys: string[]; + } | null>(null); useEffect(() => { - if (!selected) return; + if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, - memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), + environmentId, + memberKeys: members.map((member) => member.physicalProjectKey), }; - }, [selected]); + }, [selected, members, environmentId]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { - if (selected !== null) return; + if (members.length > 0) return; const last = lastSelectionRef.current; - if (last?.key !== projectKey) return; + if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ - to: "/projects/$projectKey", - params: { projectKey: successor.projectKey }, + to: "/settings/projects", + search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } - }, [groups, navigate, projectKey, selected]); + }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return ( @@ -298,17 +218,185 @@ function ProjectSettingsPanel({ projectKey }: { projectKey: string }) {
); } - return ; + if (members.length === 0) + return ( +

+ This project has no checkout on this machine. +

+ ); + const scopedGroup = { + ...selected, + memberProjects: members, + environmentId: members[0]!.environmentId, + id: members[0]!.id, + }; + return ( + + ); +} + +function reportScriptFailure(result: AtomCommandResult) { + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Failed to save project actions", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + return mapAtomCommandResult(result, () => undefined); +} + +export function useProjectScriptSettings( + targets: readonly { + environmentId: EnvironmentId; + settings: ServerSettings; + keybindings: ResolvedKeybindingsConfig; + project?: { id: ProjectId; scripts: readonly ProjectScript[] }; + }[], +) { + const projects = useProjects(); + const [saving, setSaving] = useState(false); + const savingRef = useRef(false); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); + const upsertKeybinding = useAtomCommand( + serverEnvironment.upsertKeybinding, + "action shortcut update", + ); + const removeKeybinding = useAtomCommand( + serverEnvironment.removeKeybinding, + "action shortcut removal", + ); + + async function persist( + transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, + scriptId?: string, + keybinding?: string | null, + ): Promise> { + if (savingRef.current || targets.length === 0) { + const message = "No available machine, or another action change is saving."; + toastManager.add({ type: "error", title: "Actions not saved", description: message }); + return AsyncResult.failure(Cause.fail(new Error(message))); + } + savingRef.current = true; + setSaving(true); + try { + for (const { environmentId, settings, keybindings, project } of targets) { + const current = project + ? resolveProjectScripts(settings, project) + : settings.defaultProjectScripts; + const nextScripts = transform(current); + const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; + const result = await updateSettings({ + environmentId, + input: { + patch: project + ? { projectScriptOverrides: { [project.id]: nextScripts } } + : { defaultProjectScripts: nextScripts ?? [] }, + }, + }); + if (result._tag === "Failure") return reportScriptFailure(result); + if (!isElectron) continue; + const changedIds = scriptId + ? [scriptId] + : current + .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) + .map((script) => script.id); + for (const id of changedIds) { + const command = commandForProjectScript(id); + const previousValue = keybindingValueForCommand(keybindings, command); + const previous = previousValue + ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) + : null; + const next = decodeProjectScriptKeybindingRule({ keybinding, command }); + const retainedElsewhere = + !nextScripts?.some((script) => script.id === id) && + ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || + Object.entries(settings.projectScriptOverrides).some( + ([projectId, scripts]) => + projectId !== project?.id && scripts?.some((script) => script.id === id), + ) || + projects.some( + (other) => + other.environmentId === environmentId && + other.id !== project?.id && + (project ? resolveProjectScripts(settings, other) : other.scripts).some( + (script) => script.id === id, + ), + )); + const bindingResult = next + ? await upsertKeybinding({ + environmentId, + input: + previous && previous.key !== next.key ? { ...next, replace: previous } : next, + }) + : previous && !retainedElsewhere + ? await removeKeybinding({ environmentId, input: previous }) + : null; + if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); + } + } + return AsyncResult.success(undefined); + } finally { + savingRef.current = false; + setSaving(false); + } + } + + function submit(scriptId: string | null, input: NewProjectScriptInput) { + const existingIds = [ + ...projects.flatMap((project) => project.scripts.map((script) => script.id)), + ...targets.flatMap(({ settings, project }) => + [ + ...settings.defaultProjectScripts, + ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), + ...(project?.scripts ?? []), + ].map((script) => script.id), + ), + ]; + const id = scriptId ?? nextProjectScriptId(input.name, existingIds); + const next = buildProjectScript(id, input); + return persist( + (current) => { + const updated = current.map((script) => + script.id === id + ? next + : input.runOnWorktreeCreate + ? { ...script, runOnWorktreeCreate: false } + : script, + ); + return scriptId === null ? [...updated, next] : updated; + }, + id, + input.keybinding, + ); + } + + return { saving, persist, submit }; } -function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { +function ProjectDetail({ + group, + hasOtherMembers, +}: { + group: SidebarProjectSnapshot; + hasOtherMembers: boolean; +}) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const environmentById = useMemo( + () => new Map(environments.map((environment) => [environment.environmentId, environment])), + [environments], + ); const representative = group.memberProjects.find( - (member) => member.environmentId === group.environmentId && member.id === group.id, + (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; - const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. @@ -320,28 +408,78 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); - const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); - const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { - reportFailure: false, - }); - const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { - reportFailure: false, + const updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); + const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); + const savingBrowserAccessRef = useRef(false); + const browserOverrides = group.memberProjects.map( + (member) => + environmentById.get(member.environmentId)?.serverConfig?.settings + .projectAgentBrowserAccessOverrides[member.id], + ); + const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; + const browserMixed = group.memberProjects.some((member, index) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; + return ( + browserOverrides[index] !== browserOverride || + (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== + (browserOverride ?? projectSettings.enableAgentBrowserAccess) + ); }); + const setBooleanOverride = async ( + key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", + enabled: boolean | undefined, + ) => { + if (savingBrowserAccessRef.current) return; + savingBrowserAccessRef.current = true; + setSavingBrowserAccess(true); + try { + const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); + for (const environmentId of environmentIds) { + const environment = environmentById.get(environmentId); + if (!environment?.serverConfig || environment.connection.phase !== "connected") { + toastManager.add({ + type: "warning", + title: "Setting not saved", + description: `Connect ${environment?.label ?? "this machine"} and try again.`, + }); + return; + } + } + if (key === "projectAutoPullOverrides" && enabled === undefined) { + const result = await updateAllMembers( + { autoPull: false }, + "Failed to reset automatic pull", + ); + if (result._tag === "Failure") return; + } + for (const environmentId of environmentIds) { + const overrides = Object.fromEntries( + group.memberProjects + .filter((member) => member.environmentId === environmentId) + .map((member) => [member.id, enabled ?? null]), + ); + const result = await updateServerSettings({ + environmentId, + input: { patch: { [key]: overrides } }, + }); + if (result._tag === "Failure") { + reportFailure( + `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, + mapAtomCommandResult(result, () => undefined), + ); + return; + } + } + } finally { + savingBrowserAccessRef.current = false; + setSavingBrowserAccess(false); + } + }; + const setBrowserAccess = (enabled: boolean | undefined) => + setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); + const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); - const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ - onCopy: ({ path }) => { - toastManager.add({ type: "success", title: "Path copied", description: path }); - }, - onError: (error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Failed to copy path", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - }, - }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; @@ -355,14 +493,6 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ? window.desktopBridge?.pickProjectFavicon : undefined; - const threadCountByMember = useMemo(() => { - const counts = new Map(); - for (const thread of threads) { - const key = `${thread.environmentId}:${thread.projectId}`; - counts.set(key, (counts.get(key) ?? 0) + 1); - } - return counts; - }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); @@ -437,7 +567,25 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { // ----- default model ----- const storedSelection = representative.defaultModelSelection; - const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); + const resolvedSelection = resolveDefaultProviderModelSelection( + serverProviders, + storedSelection ?? projectSettings.defaultModelSelection, + ); + const mixedModel = group.memberProjects.some((member) => { + const config = environmentById.get(member.environmentId)?.serverConfig; + return ( + !Equal.equals(member.defaultModelSelection, storedSelection) || + (config !== null && + config !== undefined && + environmentById.get(representative.environmentId)?.serverConfig != null && + JSON.stringify( + resolveDefaultProviderModelSelection( + config.providers, + member.defaultModelSelection ?? config.settings.defaultModelSelection, + ), + ) !== JSON.stringify(resolvedSelection)) + ); + }); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( @@ -461,14 +609,45 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); - const setDefaultModel = useCallback( - (selection: ModelSelection | null) => - void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), - [updateAllMembers], - ); + const setDefaultModel = (selection: ModelSelection | null) => { + if (selection !== null) { + for (const member of group.memberProjects) { + const environment = environmentById.get(member.environmentId); + const config = environment?.serverConfig; + const entry = config + ? applyProviderInstanceSettings( + deriveProviderInstanceEntries(config.providers), + config.settings, + ).find((candidate) => candidate.instanceId === selection.instanceId) + : undefined; + const options = config + ? getCustomModelOptionsByInstance( + { ...projectSettings, ...config.settings }, + config.providers, + ).get(selection.instanceId) + : undefined; + if ( + !entry?.enabled || + !entry.isAvailable || + !options?.some((model) => model.slug === selection.model && !model.isUnavailable) + ) { + toastManager.add({ + type: "warning", + title: "Project model not saved", + description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, + }); + return; + } + } + } + void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); + }; // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; + const mixedWorkspace = group.memberProjects.some( + (member) => member.defaultThreadEnvMode !== storedEnvMode, + ); const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( @@ -478,12 +657,24 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [updateAllMembers], ); - const autoPull = representative.autoPull ?? false; - const setAutoPull = useCallback( - (enabled: boolean) => - void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), - [updateAllMembers], + const autoPull = resolveProjectAutoPull( + projectSettings, + representative.id, + representative.autoPull, ); + const autoPullOverridden = group.memberProjects.some( + (member) => + member.autoPull || + environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ + member.id + ] !== undefined, + ); + const mixedAutoPull = group.memberProjects.some((member) => { + const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; + return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; + }); + const setAutoPull = (enabled: boolean | undefined) => + setBooleanOverride("projectAutoPullOverrides", enabled); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); @@ -506,27 +697,39 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { ); // ----- checkout selection and scripts ----- - const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(representative.physicalProjectKey); - const selectedCheckout = - group.memberProjects.find((member) => member.physicalProjectKey === selectedCheckoutKey) ?? - representative; + const hasMultipleCheckouts = group.memberProjects.length > 1; + const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); + const selectedCheckoutMatch = group.memberProjects.find( + (member) => member.physicalProjectKey === selectedCheckoutKey, + ); + const selectedCheckout = selectedCheckoutMatch ?? representative; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; - const scripts = selectedCheckout.scripts; + const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); + const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); + const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); - // Script writes replace the whole array, so two overlapping writes computed - // from the same snapshot would drop each other's changes. One at a time. - const [isSavingScripts, setIsSavingScripts] = useState(false); - const savingScriptsRef = useRef(false); + const { + saving: isSavingScripts, + persist: persistScripts, + submit: submitScript, + } = useProjectScriptSettings([ + { + environmentId: selectedCheckout.environmentId, + settings: scriptSettings, + keybindings, + project: selectedCheckout, + }, + ]); const t3File = useT3ProjectFileState( selectedCheckout.environmentId, selectedCheckout.workspaceRoot, ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. - const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? settings.defaultThreadEnvMode; + const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => @@ -541,135 +744,12 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { [scripts, t3File.scripts], ); - const persistScripts = useCallback( - async ( - nextScripts: ReadonlyArray>, - keybinding: string | null | undefined, - keybindingCommand: ReturnType, - ): Promise> => { - if (savingScriptsRef.current) { - return AsyncResult.failure( - Cause.fail(new Error("Another script change is still saving. Try again.")), - ); - } - savingScriptsRef.current = true; - setIsSavingScripts(true); - try { - // Captured before the write so a cleared or deleted binding can be - // removed from the keybindings config afterwards. - const previousKeybinding = keybindingValueForCommand(keybindings, keybindingCommand); - const updateResult = mapAtomCommandResult( - await updateProject({ - environmentId: selectedCheckout.environmentId, - input: { projectId: selectedCheckout.id, scripts: nextScripts }, - }), - () => undefined, - ); - if (updateResult._tag === "Failure") { - reportFailure("Failed to save scripts", updateResult); - return updateResult; - } - - const keybindingRule = decodeProjectScriptKeybindingRule({ - keybinding, - command: keybindingCommand, - }); - if (!isElectron) return updateResult; - const environmentIds = [selectedCheckout.environmentId]; - const previousTarget = previousKeybinding - ? decodeProjectScriptKeybindingRule({ - keybinding: previousKeybinding, - command: keybindingCommand, - }) - : null; - if (keybindingRule) { - // `replace` swaps the command's previous rule instead of appending a - // second one that would keep the old shortcut alive. - const input = - previousTarget && previousTarget.key !== keybindingRule.key - ? { ...keybindingRule, replace: previousTarget } - : keybindingRule; - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await upsertKeybinding({ environmentId, input }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to save keybinding", result); - return result; - } - } - } else if (previousTarget) { - for (const environmentId of environmentIds) { - const result = mapAtomCommandResult( - await removeKeybinding({ environmentId, input: previousTarget }), - () => undefined, - ); - if (result._tag === "Failure") { - reportFailure("Failed to remove keybinding", result); - return result; - } - } - } - return updateResult; - } finally { - savingScriptsRef.current = false; - setIsSavingScripts(false); - } - }, - [ - keybindings, - removeKeybinding, - reportFailure, - selectedCheckout.environmentId, - selectedCheckout.id, - updateProject, - upsertKeybinding, - ], - ); - - const submitScript = useCallback( - async ( - scriptId: string | null, - input: NewProjectScriptInput, - ): Promise> => { - if (scriptId === null) { - const nextId = nextProjectScriptId( - input.name, - scripts.map((script) => script.id), - ); - const nextScript = buildProjectScript(nextId, input); - const nextScripts = input.runOnWorktreeCreate - ? [ - ...scripts.map((script) => - script.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, - ), - nextScript, - ] - : [...scripts, nextScript]; - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(nextId)); - } - - const updatedScript = buildProjectScript(scriptId, input); - const nextScripts = scripts.map((script) => - script.id === scriptId - ? updatedScript - : input.runOnWorktreeCreate - ? { ...script, runOnWorktreeCreate: false } - : script, - ); - return persistScripts(nextScripts, input.keybinding, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); - - const deleteScript = useCallback( - (scriptId: string) => { - const nextScripts = scripts.filter((script) => script.id !== scriptId); - void persistScripts(nextScripts, null, commandForProjectScript(scriptId)); - }, - [persistScripts, scripts], - ); + const deleteScript = (scriptId: string) => + void persistScripts( + (current) => current.filter((script) => script.id !== scriptId), + scriptId, + null, + ); const importFileScript = useCallback( async (fileScript: T3ProjectFileScript) => { @@ -692,7 +772,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { }); } }, - [submitScript], + [submitScript, setEditorRequest], ); // ----- checkouts ----- @@ -720,14 +800,15 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; + const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 - ? `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` - : `Remove project "${targetLabel}"?`, + ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` + : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, @@ -741,7 +822,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), - isWholeGroup + isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", @@ -783,33 +864,50 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { draftStore.clearProjectDraftThreadId(projectRef); } - // The project's settings page just deleted itself; there is no projects - // listing to fall back to, so leave settings entirely. if (isWholeGroup) { - void navigate({ to: "/", replace: true }); + if (hasOtherMembers) { + void navigate({ + to: "/settings/projects", + search: { project: group.projectKey, machine: undefined }, + replace: true, + }); + } else { + void navigate({ to: "/", replace: true }); + } } }, [ deleteProject, group.displayName, group.memberProjects.length, + group.projectKey, + hasOtherMembers, navigate, reportFailure, threads, ], ); - const selectedCheckoutThreadCount = threadCountByMember.get(memberKey(selectedCheckout)) ?? 0; const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; - const selectedCheckoutLabel = selectedCheckout.environmentLabel ?? "This machine"; + const checkoutLabel = (member: SidebarProjectGroupMember) => { + const label = member.environmentLabel ?? "This machine"; + return group.memberProjects.some( + (other) => + other.physicalProjectKey !== member.physicalProjectKey && + (other.environmentLabel ?? "This machine") === label, + ) + ? `${label} · ${member.workspaceRoot}` + : label; + }; + const selectedCheckoutLabel = checkoutLabel(selectedCheckout); return ( <> - - + + member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null @@ -946,11 +1056,23 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null @@ -990,79 +1112,130 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { setAutoPull(false)} /> + autoPullOverridden ? ( + void setAutoPull(undefined)} + /> ) : null } control={ void setAutoPull(enabled)} /> } /> + value !== undefined) ? ( + void setBrowserAccess(undefined)} + /> + ) : null + } + control={ + + } + /> - setSelectedCheckoutKey(String(value))} - > - - {selectedCheckoutLabel} - - - {group.memberProjects.map((member) => ( - - {member.environmentLabel ?? "This machine"} · {member.workspaceRoot} - - ))} - - - } - > -
-
- - - copyPathToClipboard(selectedCheckout.workspaceRoot, { - path: selectedCheckout.workspaceRoot, - }) - } - > - - {selectedCheckout.workspaceRoot} - - - - } - /> - Copy path - -
- {selectedCheckoutThreadCount === 1 - ? "1 thread" - : `${selectedCheckoutThreadCount} threads`} -
-
-
+ + {hasMultipleCheckouts ? ( + { + if (value) setSelectedCheckoutKey(value); + }} + > + + {selectedCheckoutLabel} + + + {group.memberProjects.map((member) => ( + + + {checkoutLabel(member)} + + + ))} + + + } + /> + ) : null} updateGroupingPreference(selectedCheckout, "inherit")} + /> + ) : null + } control={ { + if (next) onChange(next === "all" ? null : next); + }} + > + + + {value === null ? allIcon : selected?.icon} + + {value === null ? `All ${label}s` : (selected?.label ?? `Unavailable ${label}`)} + + + + + + + {allIcon}All {label}s + + + {options.map((option) => ( + + + {option.icon} + {option.label} + + + ))} + + + ); +} + +export function ProjectsSettings({ + projectKey, + machineId, + onScopeChange, +}: { + projectKey: string | null; + machineId: string | null; + onScopeChange: (project: string | null, machine: string | null) => void; +}) { + const groups = useSettingsProjectGroups(); + const { environments } = useEnvironments(); + const machine = environments.find((environment) => environment.environmentId === machineId); + const machineOptions = environments.map((environment) => ({ + value: environment.environmentId, + label: environment.label, + icon: ( + + ), + })); + return ( +
+
+ +
+ {environments.length > 3 ? ( + onScopeChange(projectKey, value)} + /> + ) : ( + { + const value = next[0]; + if (value) onScopeChange(projectKey, value === "all" ? null : value); + }} + > + All machines + {machineOptions.map((option) => ( + + {option.icon} + {option.label} + + ))} + + )} +
+ ({ + value: group.projectKey, + label: group.displayName, + icon: ( + + ), + }))} + onChange={(value) => onScopeChange(value, machineId)} + /> +
+
+
+
+ {machineId !== null && !machine ? ( +

This machine is no longer available.

+ ) : projectKey === null ? ( + + ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index fe782c5757de..c3d5d7ba812f 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -2557,55 +2557,24 @@ export function GeneralSettingsPanel() { - updateSettings({ - defaultThreadEnvMode: DEFAULT_UNIFIED_SETTINGS.defaultThreadEnvMode, - newWorktreesStartFromOrigin: - DEFAULT_UNIFIED_SETTINGS.newWorktreesStartFromOrigin, - }) - } - /> - ) : null - } + description="Choose the default model and workspace for all projects or a specific project." control={ - + Project settings + } /> = { "/settings/general": Settings2Icon, "/settings/appearance": PaletteIcon, + "/settings/projects": PanelsTopLeftIcon, "/settings/keybindings": KeyboardIcon, "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, @@ -274,12 +276,18 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) { setOpenMobile(false); } const targetId = item.targetId ?? item.id; - if (pathname === item.to && currentHash.replace(/^#/, "") === targetId) { + if ( + item.to !== "/settings/projects" && + pathname === item.to && + currentHash.replace(/^#/, "") === targetId + ) { scrollToSettingsTarget(targetId); return; } void navigate({ to: item.to, + search: (previous) => + item.to === "/settings/projects" ? { ...previous, project: undefined } : previous, hash: targetId, replace: true, hashScrollIntoView: false, diff --git a/apps/web/src/components/settings/settingsLayout.tsx b/apps/web/src/components/settings/settingsLayout.tsx index 82fbed96459f..1bfa8c87146e 100644 --- a/apps/web/src/components/settings/settingsLayout.tsx +++ b/apps/web/src/components/settings/settingsLayout.tsx @@ -284,7 +284,11 @@ export function SettingsRow({ ref={targetRef} tabIndex={rowProps.id ? -1 : rowProps.tabIndex} data-slot="settings-row" - className={cn("rounded-xl px-3 sm:px-4", children ? "pt-3 pb-1" : "py-3", className)} + className={cn( + "rounded-xl px-3 sm:px-4 aria-disabled:opacity-50 aria-disabled:[&_*]:text-muted-foreground", + children ? "pt-3 pb-1" : "py-3", + className, + )} >
@@ -320,10 +324,12 @@ export function SettingsRow({ export function SettingResetButton({ label, + tooltip = "Reset to default", disabled = false, onClick, }: { label: string; + tooltip?: string; disabled?: boolean; onClick: () => void; }) { @@ -345,7 +351,7 @@ export function SettingResetButton({ } /> - Reset to default + {tooltip} ); } diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 7358ed8f9a17..f715f6ca4e6d 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -2,6 +2,7 @@ import { isElectron } from "~/env"; import { isMacPlatform, isWindowsPlatform, normalizeSearchText } from "~/lib/utils"; export type SettingsPath = + | "/settings/projects" | "/settings/general" | "/settings/appearance" | "/settings/keybindings" @@ -49,6 +50,7 @@ export interface SettingsSearchAvailability { export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/general": "General", "/settings/appearance": "Appearance", + "/settings/projects": "Projects", "/settings/keybindings": "Keybindings", "/settings/providers": "Providers", "/settings/integrations": "Integrations", @@ -63,6 +65,14 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "project-defaults", + title: "Project defaults and overrides", + to: "/settings/projects", + searchTerms: [ + "model workspace browser machines projects inheritance automatic pull checkout grouping actions scripts", + ], + }, { id: "color-scheme", title: "Color scheme", @@ -235,14 +245,13 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "new-threads", title: "New threads", - to: "/settings/general", + to: "/settings/projects", searchTerms: ["default workspace mode draft local worktree"], }, { id: "start-from-origin", title: "Start from origin", to: "/settings/general", - targetId: "new-threads", searchTerms: ["new worktrees latest matching remote branch local"], }, { @@ -345,7 +354,7 @@ export const SETTINGS_SEARCH_ITEMS = [ { id: "agent-browser-access", title: "Agent browser access", - to: "/settings/integrations", + to: "/settings/projects", searchTerms: ["allow open drive preview tools sessions"], }, { diff --git a/apps/web/src/hooks/useHandleNewThread.test.ts b/apps/web/src/hooks/useHandleNewThread.test.ts index 91b757f51e0d..afd503e63c25 100644 --- a/apps/web/src/hooks/useHandleNewThread.test.ts +++ b/apps/web/src/hooks/useHandleNewThread.test.ts @@ -49,14 +49,31 @@ const testState = vi.hoisted(() => { }); vi.mock("@effect/atom-react", () => ({ - useAtomValue: () => ({ defaultThreadEnvMode: "local", newWorktreesStartFromOrigin: false }), + useAtomValue: (atom: unknown) => + atom === "primary-settings" + ? { newWorktreesStartFromOrigin: false } + : new Map([ + [ + "environment-ssh", + { + settings: { + defaultThreadEnvMode: "local", + newWorktreesStartFromOrigin: false, + defaultModelSelection: null, + }, + }, + ], + ]), })); vi.mock("@t3tools/client-runtime/environment", () => ({ scopedProjectKey: () => "remote-project", scopeProjectRef: (environmentId: string, projectId: string) => ({ environmentId, projectId }), scopeThreadRef: (environmentId: string, threadId: string) => ({ environmentId, threadId }), })); -vi.mock("@t3tools/contracts", () => ({ DEFAULT_RUNTIME_MODE: "default" })); +vi.mock("@t3tools/contracts", () => ({ + DEFAULT_RUNTIME_MODE: "default", + DEFAULT_SERVER_SETTINGS: {}, +})); vi.mock("@t3tools/shared/threadEnvMode", () => ({ resolveDefaultThreadEnvMode: (input: { readonly projectFile: "local" | "worktree" | null; @@ -113,7 +130,10 @@ vi.mock("../state/entities", () => ({ useProjects: () => [], useThread: () => null, })); -vi.mock("../state/server", () => ({ primaryServerSettingsAtom: {} })); +vi.mock("../state/server", () => ({ + environmentServerConfigsAtom: {}, + primaryServerSettingsAtom: "primary-settings", +})); vi.mock("../threadRoutes", () => ({ resolveThreadRouteTarget: () => null })); vi.mock("../uiStateStore", () => ({ legacyProjectCwdPreferenceKey: () => "remote-project", diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index c26b25d1316b..78dfc1b13fe6 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -4,7 +4,12 @@ import { scopeProjectRef, scopeThreadRef, } from "@t3tools/client-runtime/environment"; -import { DEFAULT_RUNTIME_MODE, type ScopedProjectRef, type ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_RUNTIME_MODE, + DEFAULT_SERVER_SETTINGS, + type ScopedProjectRef, + type ThreadId, +} from "@t3tools/contracts"; import { useParams, useRouter } from "@tanstack/react-router"; import { useCallback, useMemo } from "react"; import { @@ -30,7 +35,7 @@ import { resolveNewThreadModelSelectionOverride, } from "../lib/chatThreadActions"; import { readT3ProjectFileDefaultThreadEnvMode } from "../lib/t3ProjectFileDefaults"; -import { primaryServerSettingsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerSettingsAtom } from "../state/server"; import { resolveThreadRouteTarget } from "../threadRoutes"; import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useClientSettings } from "./useSettings"; @@ -55,11 +60,7 @@ function pickExplicitWorkspaceOptions(options: NewThreadWorkspaceOptions | undef } export function useNewThreadHandler() { - // New-thread defaults are a user preference, and the settings UI only ever - // edits the primary environment's settings.json. Reading the target - // environment's own settings here would silently reset remote projects to - // the decoded defaults ("local" mode, current branch), since nothing can - // set those values on a remote server. + const environmentServerConfigs = useAtomValue(environmentServerConfigsAtom); const primaryServerSettings = useAtomValue(primaryServerSettingsAtom); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const router = useRouter(); @@ -83,6 +84,8 @@ export function useNewThreadHandler() { // up again and finding whichever draft it happens to hold. ): Promise<{ draftId: DraftId; threadId: ThreadId } | null> => { const projects = readProjects(); + const targetServerSettings = + environmentServerConfigs.get(projectRef.environmentId)?.settings ?? DEFAULT_SERVER_SETTINGS; const { getComposerDraft, getDraftSessionByLogicalProjectKey, @@ -138,7 +141,8 @@ export function useNewThreadHandler() { ); const resolveModelSelectionOverride = (destinationDraftId: DraftId) => resolveNewThreadModelSelectionOverride({ - projectDefaultSelection: project?.defaultModelSelection ?? null, + projectDefaultSelection: + project?.defaultModelSelection ?? targetServerSettings.defaultModelSelection ?? null, carrySelection: carryModelSelection, carrySourceDraftId: currentRouteTarget?.kind === "draft" ? currentRouteTarget.draftId : null, @@ -157,7 +161,7 @@ export function useNewThreadHandler() { project.workspaceRoot, ) : null, - globalDefault: primaryServerSettings.defaultThreadEnvMode, + globalDefault: targetServerSettings.defaultThreadEnvMode, }); }; const logicalProjectKey = project @@ -429,7 +433,13 @@ export function useNewThreadHandler() { return { draftId, threadId }; })(); }, - [getCurrentRouteTarget, primaryServerSettings, projectGroupingSettings, router], + [ + environmentServerConfigs, + getCurrentRouteTarget, + primaryServerSettings.newWorktreesStartFromOrigin, + projectGroupingSettings, + router, + ], ); } diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 5c796f3ab6c8..b1a9d0b9e04b 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' +import { Route as SettingsProjectsRouteImport } from './routes/settings.projects' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' import { Route as SettingsIntegrationsRouteImport } from './routes/settings.integrations' import { Route as SettingsGeneralRouteImport } from './routes/settings.general' @@ -75,6 +76,11 @@ const SettingsProvidersRoute = SettingsProvidersRouteImport.update({ path: '/providers', getParentRoute: () => SettingsRoute, } as any) +const SettingsProjectsRoute = SettingsProjectsRouteImport.update({ + id: '/projects', + path: '/projects', + getParentRoute: () => SettingsRoute, +} as any) const SettingsKeybindingsRoute = SettingsKeybindingsRouteImport.update({ id: '/keybindings', path: '/keybindings', @@ -154,6 +160,7 @@ export interface FileRoutesByFullPath { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute @@ -175,6 +182,7 @@ export interface FileRoutesByTo { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/': typeof ChatIndexRoute @@ -199,6 +207,7 @@ export interface FileRoutesById { '/settings/general': typeof SettingsGeneralRoute '/settings/integrations': typeof SettingsIntegrationsRoute '/settings/keybindings': typeof SettingsKeybindingsRoute + '/settings/projects': typeof SettingsProjectsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute '/_chat/': typeof ChatIndexRoute @@ -224,6 +233,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/$environmentId/$threadId' @@ -245,6 +255,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/' @@ -268,6 +279,7 @@ export interface FileRouteTypes { | '/settings/general' | '/settings/integrations' | '/settings/keybindings' + | '/settings/projects' | '/settings/providers' | '/settings/source-control' | '/_chat/' @@ -351,6 +363,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SettingsProvidersRouteImport parentRoute: typeof SettingsRoute } + '/settings/projects': { + id: '/settings/projects' + path: '/projects' + fullPath: '/settings/projects' + preLoaderRoute: typeof SettingsProjectsRouteImport + parentRoute: typeof SettingsRoute + } '/settings/keybindings': { id: '/settings/keybindings' path: '/keybindings' @@ -462,6 +481,7 @@ interface SettingsRouteChildren { SettingsGeneralRoute: typeof SettingsGeneralRoute SettingsIntegrationsRoute: typeof SettingsIntegrationsRoute SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute + SettingsProjectsRoute: typeof SettingsProjectsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute } @@ -474,6 +494,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsGeneralRoute: SettingsGeneralRoute, SettingsIntegrationsRoute: SettingsIntegrationsRoute, SettingsKeybindingsRoute: SettingsKeybindingsRoute, + SettingsProjectsRoute: SettingsProjectsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, } diff --git a/apps/web/src/routes/projects.$projectKey.tsx b/apps/web/src/routes/projects.$projectKey.tsx index 6ae03719c042..d636c0a953ef 100644 --- a/apps/web/src/routes/projects.$projectKey.tsx +++ b/apps/web/src/routes/projects.$projectKey.tsx @@ -1,15 +1,17 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; -import { ProjectSettingsPage } from "../components/settings/ProjectSettingsPanel"; - export const Route = createFileRoute("/projects/$projectKey")({ - beforeLoad: async ({ context }) => { + beforeLoad: async ({ context, params }) => { if ( context.authGateState.status !== "authenticated" && context.authGateState.status !== "hosted-static" ) { throw redirect({ to: "/pair", replace: true }); } + throw redirect({ + to: "/settings/projects", + search: { project: params.projectKey, machine: undefined }, + replace: true, + }); }, - component: () => , }); diff --git a/apps/web/src/routes/settings.projects.tsx b/apps/web/src/routes/settings.projects.tsx new file mode 100644 index 000000000000..fa79f46fbb2c --- /dev/null +++ b/apps/web/src/routes/settings.projects.tsx @@ -0,0 +1,27 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { ProjectsSettings } from "../components/settings/ProjectsSettings"; + +export const Route = createFileRoute("/settings/projects")({ + validateSearch: (search: Record) => ({ + project: typeof search.project === "string" ? search.project : undefined, + machine: typeof search.machine === "string" ? search.machine : undefined, + }), + component: ProjectsRoute, +}); + +function ProjectsRoute() { + const { project, machine } = Route.useSearch(); + const navigate = Route.useNavigate(); + return ( + { + void navigate({ + search: { project: project ?? undefined, machine: machine ?? undefined }, + replace: true, + }); + }} + /> + ); +} diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 747bc52c07ec..c76c18544df2 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -1,11 +1,28 @@ # Project settings -Open **Settings → Projects** and select a project to change its preferences. +Open **Settings → Projects**. The project and machine pickers start at **All projects** and +**All machines**. + +Change the default model, workspace, automatic pull, agent browser access, or actions for projects that inherit those values. +Select an individual project to override a default. Reset its row to inherit again. Changing a +default preserves explicit project overrides. Workspace preferences in `t3.json` take precedence +over machine defaults when the project has no explicit workspace override. + +Select a machine to limit edits to it. **All machines** writes defaults to connected machines; +offline machines keep their previous values. Mixed values are indicated when selected machines +or checkouts disagree. Browser access changes apply when an agent session next starts. + +Project grouping has a client-wide default across machines, with individual checkout overrides. +Shared actions apply to inheriting projects; editing a project's actions creates an independent list. +Reset that list to use shared actions again. Existing project actions are preserved. + +Project names, icons, removal, and importing actions from a checkout remain project-specific. +When there are several checkouts, the checkout picker selects which actions and grouping to edit. ## Project icons Choose an icon, emoji, or image from the project to make it easier to recognize. The choice applies -to every checkout in the project group and appears on connected clients. Choose **Automatic** to +to selected checkouts in the project group and appears on connected clients. Choose **Automatic** to let T3 Code detect an icon again. ## Keep the default branch current diff --git a/packages/client-runtime/src/state/sharedSettings.test.ts b/packages/client-runtime/src/state/sharedSettings.test.ts index 8cf0e3bc7f08..712138aab4c9 100644 --- a/packages/client-runtime/src/state/sharedSettings.test.ts +++ b/packages/client-runtime/src/state/sharedSettings.test.ts @@ -44,13 +44,19 @@ describe("splitSharedServerPatch", () => { sidebarAutoSettleOnMerge: false, continueThreadsAfterServerUpdate: true, enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", + newWorktreesStartFromOrigin: true, }); expect(sharedPatch).toEqual({ sidebarAutoSettleAfterDays: 7, sidebarAutoSettleOnMerge: false, continueThreadsAfterServerUpdate: true, + newWorktreesStartFromOrigin: true, + }); + expect(localPatch).toEqual({ + enableAgentBrowserAccess: false, + defaultThreadEnvMode: "worktree", }); - expect(localPatch).toEqual({ enableAgentBrowserAccess: false }); }); }); @@ -60,7 +66,6 @@ describe("pickSharedServerSettings", () => { Object.keys(pickSharedServerSettings(DEFAULT_SERVER_SETTINGS, restartCapabilities)).sort(), ).toEqual([ "continueThreadsAfterServerUpdate", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", @@ -206,7 +211,12 @@ describe("findSharedSettingsMismatches", () => { environmentId: boxId, label: "Remote Box", syncEligible: true, - settings: { ...primarySettings, enableAgentBrowserAccess: false }, + settings: { + ...primarySettings, + enableAgentBrowserAccess: false, + defaultThreadEnvMode: + primarySettings.defaultThreadEnvMode === "local" ? "worktree" : "local", + }, }, ], }); diff --git a/packages/client-runtime/src/state/sharedSettings.ts b/packages/client-runtime/src/state/sharedSettings.ts index 128d5c25464b..0fd691a64bcd 100644 --- a/packages/client-runtime/src/state/sharedSettings.ts +++ b/packages/client-runtime/src/state/sharedSettings.ts @@ -24,7 +24,6 @@ const SHARED_SERVER_SETTING_KEYS = [ "continueThreadsAfterServerUpdate", "sidebarAutoSettleAfterDays", "sidebarAutoSettleOnMerge", - "defaultThreadEnvMode", "newWorktreesStartFromOrigin", "sourceControlWritingStyle", ] as const satisfies ReadonlyArray; diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index ce9082477372..983e17b54370 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -2,7 +2,12 @@ import * as Effect from "effect/Effect"; import * as Duration from "effect/Duration"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; -import { ForwardCompatibleNullable, TrimmedNonEmptyString, TrimmedString } from "./baseSchemas.ts"; +import { + ForwardCompatibleNullable, + ProjectId, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; import { UsageLimitSourceId } from "./usageLimitSourceId.ts"; import { EnvironmentMachineKind, ThreadEnvMode } from "./environment.ts"; import { @@ -11,7 +16,7 @@ import { DEFAULT_TEXT_GENERATION_REASONING_EFFORT, ProviderOptionSelections, } from "./model.ts"; -import { ModelSelection } from "./orchestration.ts"; +import { ModelSelection, ProjectScript } from "./orchestration.ts"; import { BrowserProfile, BrowserProfileId, DEFAULT_BROWSER_PROFILE_ID } from "./browserProfile.ts"; import { DEFAULT_PREVIEW_APPEARANCE, @@ -856,6 +861,22 @@ 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))), + projectAgentBrowserAccessOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultAutoPull: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + defaultProjectScripts: Schema.Array(ProjectScript).pipe( + Schema.withDecodingDefault(Effect.succeed([])), + ), + projectScriptOverrides: Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + projectAutoPullOverrides: Schema.Record(ProjectId, Schema.Boolean).pipe( + Schema.withDecodingDefault(Effect.succeed({})), + ), + defaultModelSelection: Schema.NullOr(ModelSelection).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), sidebarAutoSettleAfterDays: Schema.NullOr(SidebarAutoSettleAfterDays).pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_AUTO_SETTLE_AFTER_DAYS)), ), @@ -1116,6 +1137,18 @@ export const ServerSettingsPatch = Schema.Struct({ enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean), continueThreadsAfterServerUpdate: Schema.optionalKey(Schema.Boolean), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), + projectAgentBrowserAccessOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultAutoPull: Schema.optionalKey(Schema.Boolean), + defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), + projectScriptOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Array(ProjectScript))), + ), + projectAutoPullOverrides: Schema.optionalKey( + Schema.Record(ProjectId, Schema.NullOr(Schema.Boolean)), + ), + defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sidebarAutoSettleAfterDays: Schema.optionalKey(Schema.NullOr(SidebarAutoSettleAfterDays)), sidebarAutoSettleOnMerge: Schema.optionalKey(Schema.Boolean), backgroundActivity: Schema.optionalKey( diff --git a/packages/shared/src/projectScripts.ts b/packages/shared/src/projectScripts.ts index 199a55bf3cbf..4d98e36b4d70 100644 --- a/packages/shared/src/projectScripts.ts +++ b/packages/shared/src/projectScripts.ts @@ -1,4 +1,24 @@ -import type { ProjectScript } from "@t3tools/contracts"; +import type { ProjectId, ProjectScript, ServerSettings } from "@t3tools/contracts"; + +/** Missing entries preserve existing actions; null explicitly resets a checkout to machine defaults. */ +export function resolveProjectScripts( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): readonly ProjectScript[] { + const override = settings.projectScriptOverrides[project.id]; + if (override === null) return settings.defaultProjectScripts; + return ( + override ?? (project.scripts.length > 0 ? project.scripts : settings.defaultProjectScripts) + ); +} + +export function projectScriptsInheritDefaults( + settings: Pick, + project: { id: ProjectId; scripts: readonly ProjectScript[] }, +): boolean { + const override = settings.projectScriptOverrides[project.id]; + return override === null || (override === undefined && project.scripts.length === 0); +} interface ProjectScriptRuntimeEnvInput { project: { diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index 31f056c211e9..a5e428fcdaac 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -1,5 +1,6 @@ import { DEFAULT_SERVER_SETTINGS, + ProjectId, ProviderDriverKind, ProviderInstanceId, UsageLimitSourceId, @@ -9,14 +10,181 @@ import * as Duration from "effect/Duration"; import { describe, expect, it } from "vite-plus/test"; import { resolveServerBackgroundActivitySettings } from "./backgroundActivitySettings.ts"; import { createModelSelection } from "./model.ts"; +import { resolveProjectScripts, projectScriptsInheritDefaults } from "./projectScripts.ts"; import { applyServerSettingsPatch, isModelSelectionProviderEnabled, parsePersistedServerObservabilitySettings, resolveSourceControlWriterModelSelection, + resolveProjectAgentBrowserAccess, + resolveProjectAutoPull, } from "./serverSettings.ts"; describe("serverSettings helpers", () => { + it("inherits actions, preserves existing actions, and supports empty overrides and reset", () => { + const project = { id: ProjectId.make("project-actions"), scripts: [] }; + const action = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const defaults = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [action], + }); + expect(resolveProjectScripts(defaults, project)).toEqual([action]); + expect(projectScriptsInheritDefaults(defaults, project)).toBe(true); + const existing = { ...project, scripts: [{ ...action, command: "npm run lint" }] }; + expect(resolveProjectScripts(defaults, existing)).toEqual(existing.scripts); + expect(projectScriptsInheritDefaults(defaults, existing)).toBe(false); + const disabled = applyServerSettingsPatch(defaults, { + projectScriptOverrides: { [project.id]: [] }, + }); + expect(resolveProjectScripts(disabled, project)).toEqual([]); + expect(projectScriptsInheritDefaults(disabled, project)).toBe(false); + const changedDefault = applyServerSettingsPatch(disabled, { + defaultProjectScripts: [{ ...action, command: "npm run build" }], + }); + expect(resolveProjectScripts(changedDefault, project)).toEqual([]); + const reset = applyServerSettingsPatch(changedDefault, { + projectScriptOverrides: { [project.id]: null }, + }); + expect(resolveProjectScripts(reset, existing)).toEqual(changedDefault.defaultProjectScripts); + expect(projectScriptsInheritDefaults(reset, existing)).toBe(true); + expect( + resolveProjectScripts( + applyServerSettingsPatch(reset, { defaultProjectScripts: [] }), + existing, + ), + ).toEqual([]); + }); + + it("preserves other projects' actions when overriding, clearing, or resetting one project", () => { + const firstProject = { id: ProjectId.make("first-project"), scripts: [] }; + const secondProject = { id: ProjectId.make("second-project"), scripts: [] }; + const defaultAction = { + id: "check", + name: "Check", + command: "npm test", + icon: "play" as const, + runOnWorktreeCreate: false, + }; + const firstAction = { ...defaultAction, command: "npm run lint" }; + const secondAction = { ...defaultAction, command: "npm run build" }; + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultProjectScripts: [defaultAction], + projectScriptOverrides: { [firstProject.id]: [firstAction] }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectScriptOverrides: { [secondProject.id]: [secondAction] }, + }); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + expect(resolveProjectScripts(secondUpdate, secondProject)).toEqual([secondAction]); + + const cleared = applyServerSettingsPatch(secondUpdate, { + projectScriptOverrides: { [firstProject.id]: [] }, + }); + expect(resolveProjectScripts(cleared, firstProject)).toEqual([]); + expect(resolveProjectScripts(cleared, secondProject)).toEqual([secondAction]); + + const reset = applyServerSettingsPatch(cleared, { + projectScriptOverrides: { [firstProject.id]: null }, + }); + expect(resolveProjectScripts(reset, { ...firstProject, scripts: [firstAction] })).toEqual([ + defaultAction, + ]); + expect(resolveProjectScripts(reset, secondProject)).toEqual([secondAction]); + expect(resolveProjectScripts(secondUpdate, firstProject)).toEqual([firstAction]); + }); + + it("inherits automatic pull while preserving legacy opt-ins and explicit overrides", () => { + const projectId = ProjectId.make("project-pull"); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, false)).toBe(false); + expect(resolveProjectAutoPull(DEFAULT_SERVER_SETTINGS, projectId, true)).toBe(true); + const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { defaultAutoPull: true }); + expect(resolveProjectAutoPull(enabled, projectId, false)).toBe(true); + const overridden = applyServerSettingsPatch(enabled, { + projectAutoPullOverrides: { [projectId]: false }, + }); + expect(resolveProjectAutoPull(overridden, projectId, true)).toBe(false); + const reset = applyServerSettingsPatch(overridden, { + projectAutoPullOverrides: { [projectId]: null }, + }); + expect(resolveProjectAutoPull(reset, projectId, false)).toBe(true); + const disabled = applyServerSettingsPatch(reset, { + defaultAutoPull: false, + projectAutoPullOverrides: { [projectId]: true }, + }); + expect(resolveProjectAutoPull(disabled, projectId, false)).toBe(true); + expect(resolveProjectAutoPull(disabled, ProjectId.make("other-project"), false)).toBe(false); + }); + + it("inherits browser access and restores inheritance when a project override is removed", () => { + const projectId = ProjectId.make("project-browser"); + const otherProjectId = ProjectId.make("other-project"); + const overridden = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + projectAgentBrowserAccessOverrides: { [projectId]: false }, + }); + expect(resolveProjectAgentBrowserAccess(overridden, projectId)).toBe(false); + expect(resolveProjectAgentBrowserAccess(overridden, otherProjectId)).toBe(true); + const reset = applyServerSettingsPatch(overridden, { + projectAgentBrowserAccessOverrides: { [projectId]: null }, + }); + expect(resolveProjectAgentBrowserAccess(reset, projectId)).toBe(true); + const enabled = applyServerSettingsPatch(reset, { + enableAgentBrowserAccess: false, + projectAgentBrowserAccessOverrides: { [projectId]: true }, + }); + expect(resolveProjectAgentBrowserAccess(enabled, projectId)).toBe(true); + expect(resolveProjectAgentBrowserAccess(enabled, otherProjectId)).toBe(false); + }); + + it("preserves other projects' boolean overrides across separate updates and resets", () => { + const firstProjectId = ProjectId.make("first-project"); + const secondProjectId = ProjectId.make("second-project"); + const firstUpdate = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultAutoPull: true, + projectAutoPullOverrides: { [firstProjectId]: false }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: false }, + }); + const secondUpdate = applyServerSettingsPatch(firstUpdate, { + projectAutoPullOverrides: { [secondProjectId]: false }, + projectAgentBrowserAccessOverrides: { [secondProjectId]: false }, + }); + for (const projectId of [firstProjectId, secondProjectId]) { + expect(resolveProjectAutoPull(secondUpdate, projectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, projectId)).toBe(false); + } + + const reset = applyServerSettingsPatch(secondUpdate, { + projectAutoPullOverrides: { [firstProjectId]: null }, + projectAgentBrowserAccessOverrides: { [firstProjectId]: null }, + }); + expect(resolveProjectAutoPull(reset, firstProjectId, false)).toBe(true); + expect(resolveProjectAgentBrowserAccess(reset, firstProjectId)).toBe(true); + expect(resolveProjectAutoPull(reset, secondProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(reset, secondProjectId)).toBe(false); + expect(reset.projectAutoPullOverrides[firstProjectId]).toBeUndefined(); + expect(reset.projectAgentBrowserAccessOverrides[firstProjectId]).toBeUndefined(); + expect(resolveProjectAutoPull(secondUpdate, firstProjectId, false)).toBe(false); + expect(resolveProjectAgentBrowserAccess(secondUpdate, firstProjectId)).toBe(false); + }); + + it("replaces and clears conversation model defaults without retaining old options", () => { + const current = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { + defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5.4", [ + { id: "reasoningEffort", value: "high" }, + ]), + }); + const selection = createModelSelection(ProviderInstanceId.make("claudeAgent"), "sonnet"); + const updated = applyServerSettingsPatch(current, { defaultModelSelection: selection }); + expect(updated.defaultModelSelection).toEqual(selection); + expect( + applyServerSettingsPatch(updated, { defaultModelSelection: null }).defaultModelSelection, + ).toBeNull(); + }); + it("ignores missing and blank persisted observability URLs", () => { expect(parsePersistedServerObservabilitySettings("{}")).toEqual({ otlpTracesUrl: undefined, diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index dc50da2d7627..f969e4412c30 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -3,6 +3,7 @@ import { isProviderAvailable, resolveProviderInstanceEnabled, type ModelSelection, + type ProjectId, type ProviderDriverKind, type ServerProvider, ServerSettings, @@ -23,6 +24,27 @@ import { const ServerSettingsJson = fromLenientJson(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownOption(ServerSettingsJson); +export function resolveProjectAgentBrowserAccess( + settings: Pick, + projectId: ProjectId, +): boolean { + return ( + settings.projectAgentBrowserAccessOverrides[projectId] ?? settings.enableAgentBrowserAccess + ); +} + +export function resolveProjectAutoPull( + settings: Pick, + projectId: ProjectId, + legacyAutoPull: boolean | undefined, +): boolean { + // Existing opt-ins stay enabled until explicitly overridden or reset. + return ( + settings.projectAutoPullOverrides[projectId] ?? + (legacyAutoPull === true || settings.defaultAutoPull) + ); +} + type LegacyProviderSettings = ServerSettings["providers"][keyof ServerSettings["providers"]]; const getLegacyProviderSettings = ( @@ -151,6 +173,8 @@ export function applyServerSettingsPatch( // Merged per entry below; its `null` removals must not reach deepMerge. usageLimitSources: usageLimitSourcesPatch, usagePriceOverrides: usagePriceOverridesPatch, + projectAgentBrowserAccessOverrides: projectAgentBrowserAccessOverridesPatch, + projectAutoPullOverrides: projectAutoPullOverridesPatch, ...patchForMerge } = patch; const currentBackgroundActivity = normalizeServerBackgroundActivitySettings(current); @@ -207,6 +231,36 @@ export function applyServerSettingsPatch( ...(patch.providerInstances !== undefined ? { providerInstances: patch.providerInstances } : {}), + ...(projectAgentBrowserAccessOverridesPatch !== undefined + ? { + projectAgentBrowserAccessOverrides: mergeSettingsEntries( + current.projectAgentBrowserAccessOverrides, + projectAgentBrowserAccessOverridesPatch, + ), + } + : {}), + ...(projectAutoPullOverridesPatch !== undefined + ? { + projectAutoPullOverrides: mergeSettingsEntries( + current.projectAutoPullOverrides, + projectAutoPullOverridesPatch, + ), + } + : {}), + ...(patch.defaultModelSelection !== undefined + ? { defaultModelSelection: patch.defaultModelSelection } + : {}), + ...(patch.defaultProjectScripts !== undefined + ? { defaultProjectScripts: patch.defaultProjectScripts } + : {}), + ...(patch.projectScriptOverrides !== undefined + ? { + projectScriptOverrides: { + ...current.projectScriptOverrides, + ...patch.projectScriptOverrides, + }, + } + : {}), ...(usageLimitSourcesPatch !== undefined ? { usageLimitSources: mergeSettingsEntries( From 420fd76f60433fe05b8d2c76f4fbde430dc49968 Mon Sep 17 00:00:00 2001 From: maria Date: Sun, 6 Sep 2026 02:33:13 -0300 Subject: [PATCH 189/320] feat(connections): balance new threads across connected machines (#9895) --- .../settings/DesktopClientSettings.test.ts | 2 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/resourceTelemetry/HostResources.ts | 93 +++++++++++++ apps/server/src/server.test.ts | 94 ++++++++++++- apps/server/src/server.ts | 2 + apps/server/src/ws.ts | 6 + apps/web/src/components/BranchToolbar.tsx | 52 ++++++- .../BranchToolbarBranchSelector.tsx | 6 +- .../BranchToolbarEnvironmentSelector.tsx | 46 ++++-- apps/web/src/components/ChatView.tsx | 131 ++++++++++++++++++ apps/web/src/components/GitActionsControl.tsx | 8 +- .../settings/ConnectionsSettings.tsx | 2 + .../settings/LoadBalancingSettings.tsx | 94 +++++++++++++ .../src/components/settings/settingsSearch.ts | 8 ++ apps/web/src/composerDraftStore.test.ts | 57 ++++++++ apps/web/src/composerDraftStore.ts | 59 ++++++++ .../src/hooks/useLoadBalancedEnvironment.ts | 50 +++++++ docs/user/remote-access.md | 17 +++ packages/client-runtime/package.json | 4 + packages/client-runtime/src/load-balancing.ts | 40 ++++++ .../src/state/projectGrouping.test.ts | 65 +++++++++ packages/client-runtime/src/state/server.ts | 7 + packages/contracts/src/resourceTelemetry.ts | 10 ++ packages/contracts/src/rpc.ts | 9 ++ packages/contracts/src/settings.test.ts | 15 ++ packages/contracts/src/settings.ts | 9 ++ 26 files changed, 865 insertions(+), 22 deletions(-) create mode 100644 apps/server/src/resourceTelemetry/HostResources.ts create mode 100644 apps/web/src/components/settings/LoadBalancingSettings.tsx create mode 100644 apps/web/src/hooks/useLoadBalancedEnvironment.ts create mode 100644 packages/client-runtime/src/load-balancing.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9fbacc832a90..89e1a7fb19e3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -58,6 +58,8 @@ const clientSettings: ClientSettings = { sidebarThreadSortOrder: "created_at", sidebarThreadPreviewCount: 6, legacySidebarEnabled: false, + loadBalancingEnabled: false, + loadBalancingWeights: { "environment-1": 75, "environment-2": 0 }, timestampFormat: "24-hour", wordWrap: true, }; diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 7bd1ed6c45f1..a069322aa8bf 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -53,6 +53,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, + [WS_METHODS.serverGetHostResources]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessResourceHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverGetResourceTelemetryHistory]: AuthOrchestrationReadScope, [WS_METHODS.serverRetryResourceTelemetry]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/resourceTelemetry/HostResources.ts b/apps/server/src/resourceTelemetry/HostResources.ts new file mode 100644 index 000000000000..032832dd4869 --- /dev/null +++ b/apps/server/src/resourceTelemetry/HostResources.ts @@ -0,0 +1,93 @@ +import * as NodeOS from "node:os"; +import type { HostResourcesSnapshot } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Cache from "effect/Cache"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +export class HostResources extends Context.Service< + HostResources, + { readonly read: Effect.Effect } +>()("t3/resourceTelemetry/HostResources") {} + +function readCpu() { + const cpus = NodeOS.cpus(); + const cpu = cpus.reduce( + (sum, { times }) => ({ + idle: sum.idle + times.idle, + total: sum.total + times.user + times.nice + times.sys + times.idle + times.irq, + }), + { idle: 0, total: 0 }, + ); + return { ...cpu, count: cpus.length }; +} + +function darwinAvailableMemory(output: string): number | null { + const pageSize = /page size of (\d+) bytes/.exec(output)?.[1]; + const free = /^Pages free:\s+(\d+)\./m.exec(output)?.[1]; + const inactive = /^Pages inactive:\s+(\d+)\./m.exec(output)?.[1]; + const speculative = /^Pages speculative:\s+(\d+)\./m.exec(output)?.[1]; + if (!pageSize || !free || !inactive || !speculative) return null; + // vm_stat subtracts speculative pages from its printed "Pages free" count. + // Adding them here counts each reclaimable page once; purgeable pages overlap. + const available = (Number(free) + Number(inactive) + Number(speculative)) * Number(pageSize); + return Number.isSafeInteger(available) && Number(pageSize) > 0 ? available : null; +} + +export const make = Effect.fn("makeHostResources")(function* () { + const fs = yield* FileSystem.FileSystem; + const platform = yield* HostProcessPlatform; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + + const sample = Effect.fn("HostResources.sample")(function* () { + const previousCpu = readCpu(); + // CPU counters need two readings; idle servers do no polling or process scans. + yield* Effect.sleep("200 millis"); + const cpu = readCpu(); + const totalDelta = cpu.total - previousCpu.total; + const idleDelta = cpu.idle - previousCpu.idle; + const cpuUtilization = + previousCpu.count === cpu.count && totalDelta > 0 && idleDelta >= 0 + ? Math.min(1, Math.max(0, 1 - idleDelta / totalDelta)) + : null; + const totalMemoryBytes = NodeOS.totalmem(); + // On Windows libuv returns GlobalMemoryStatusEx.ullAvailPhys, including standby memory. + let availableMemoryBytes = NodeOS.freemem(); + if (platform === "linux") { + const meminfo = yield* fs + .readFileString("/proc/meminfo") + .pipe(Effect.catch(() => Effect.succeed(""))); + const available = /^MemAvailable:\s+(\d+)\s+kB$/m.exec(meminfo)?.[1]; + if (available) availableMemoryBytes = Number(available) * 1024; + } else if (platform === "darwin") { + const output = yield* spawner + .string(ChildProcess.make("/usr/bin/vm_stat", [], { stdin: "ignore", stderr: "ignore" })) + .pipe( + Effect.timeout("1 second"), + Effect.catch(() => Effect.succeed("")), + ); + availableMemoryBytes = darwinAvailableMemory(output) ?? availableMemoryBytes; + } + return { + sampledAt: DateTime.toEpochMillis(yield* DateTime.now), + cpuUtilization, + cpuCount: cpu.count, + availableMemoryBytes: Math.min(totalMemoryBytes, Math.max(0, availableMemoryBytes)), + totalMemoryBytes, + }; + }); + + // One server-lifetime cache deduplicates simultaneous requests from all sockets. + const cache = yield* Cache.make({ + capacity: 1, + lookup: (_key: "host") => sample(), + timeToLive: "5 seconds", + }); + return HostResources.of({ read: Cache.get(cache, "host") }); +}); + +export const layer = Layer.effect(HostResources, make()); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 56889c1e63b2..64bfea1b9805 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -165,6 +165,7 @@ import * as PairingGrantStore from "./auth/PairingGrantStore.ts"; import * as CloudManagedEndpointRuntime from "./cloud/ManagedEndpointRuntime.ts"; import * as CloudCliTokenManager from "./cloud/CliTokenManager.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -845,7 +846,8 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide( + Layer.provide([ + HostResources.layer, Layer.mock(ProcessResourceMonitor.ProcessResourceMonitor)({ readHistory: (input) => Effect.succeed({ @@ -860,7 +862,7 @@ const buildAppUnderTest = (options?: { error: Option.none(), }), }), - ), + ]), Layer.provide( Layer.mock(TraceDiagnostics.TraceDiagnostics)({ read: () => @@ -6154,6 +6156,94 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("returns cached whole-host resources over websocket", () => + Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + const [first, second] = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.all( + [ + client[WS_METHODS.serverGetHostResources]({}), + client[WS_METHODS.serverGetHostResources]({}), + ], + { concurrency: "unbounded" }, + ), + ), + ); + assert.deepEqual(first, second); + assert.isAtLeast(first.sampledAt, 0); + assert.isAbove(first.cpuCount, 0); + assert.isAbove(first.totalMemoryBytes, 0); + assert.isAtLeast(first.availableMemoryBytes, 0); + assert.isAtMost(first.availableMemoryBytes, first.totalMemoryBytes); + if (first.cpuUtilization !== null) { + assert.isAtLeast(first.cpuUtilization, 0); + assert.isAtMost(first.cpuUtilization, 1); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), + ); + + it.effect("counts macOS reclaimable memory once and shares concurrent samples", () => + Effect.gen(function* () { + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Ref.update(commandCalls, (count) => count + 1).pipe( + Effect.as( + "Mach Virtual Memory Statistics: (page size of 16384 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + + "Pages purgeable: 999.\n", + ), + ), + }), + ), + ); + const [first, second] = yield* Effect.all([hostResources.read, hostResources.read], { + concurrency: "unbounded", + }); + assert.equal(first.availableMemoryBytes, 35 * 16384); + assert.deepEqual(first, second); + assert.deepEqual(yield* hostResources.read, first); + assert.equal(yield* Ref.get(commandCalls), 1); + }).pipe(TestClock.withLive), + ); + + it.effect("retries host sampling immediately after its caller is interrupted", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const commandCalls = yield* Ref.make(0); + const hostResources = yield* HostResources.make().pipe( + Effect.provideService(HostProcessPlatform, "darwin"), + Effect.provide( + Layer.mock(ChildProcessSpawner.ChildProcessSpawner)({ + string: () => + Effect.gen(function* () { + const call = yield* Ref.updateAndGet(commandCalls, (count) => count + 1); + if (call === 1) { + yield* Deferred.succeed(started, undefined); + return yield* Effect.never; + } + return ( + "Mach Virtual Memory Statistics: (page size of 4096 bytes)\n" + + "Pages free: 10.\nPages inactive: 20.\nPages speculative: 5.\n" + ); + }), + }), + ), + ); + const firstRead = yield* hostResources.read.pipe(Effect.forkChild); + yield* Deferred.await(started); + yield* Fiber.interrupt(firstRead); + const recovered = yield* hostResources.read; + assert.equal(recovered.availableMemoryBytes, 35 * 4096); + assert.equal(yield* Ref.get(commandCalls), 2); + }).pipe(TestClock.withLive), + ); + it.effect("routes websocket resource telemetry through the subscription", () => Effect.gen(function* () { yield* buildAppUnderTest(); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 4abe43d8a631..ce39ee64f51a 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -115,6 +115,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as DesktopAppUpdate from "./desktopUpdate/DesktopAppUpdate.ts"; import * as ServiceLauncherClient from "./cloud/serviceLauncherClient.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as DesktopTelemetryReceiver from "./resourceTelemetry/DesktopTelemetryReceiver.ts"; @@ -199,6 +200,7 @@ const BackgroundLayerLive = BackgroundPolicy.layer.pipe( const UsageLayerLive = UsageService.layer.pipe(Layer.provide(ServerSettingsLayerLive)); const ResourceDiagnosticsLayerLive = Layer.mergeAll( + HostResources.layer, ResourceTelemetryLayerLive, ProcessDiagnostics.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), ProcessResourceMonitor.layer.pipe(Layer.provide(ResourceTelemetryLayerLive)), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index fa29d5bd9847..b4255c647816 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -135,6 +135,7 @@ import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; +import * as HostResources from "./resourceTelemetry/HostResources.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageLimitSources from "./usage/UsageLimitSources.ts"; import * as UsageService from "./usage/UsageService.ts"; @@ -605,6 +606,7 @@ const makeWsRpcLayer = ( const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; + const hostResources = yield* HostResources.HostResources; const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; @@ -2020,6 +2022,10 @@ const makeWsRpcLayer = ( observeRpcEffect(WS_METHODS.serverGetProcessDiagnostics, processDiagnostics.read, { "rpc.aggregate": "server", }), + [WS_METHODS.serverGetHostResources]: (_input) => + observeRpcEffect(WS_METHODS.serverGetHostResources, hostResources.read, { + "rpc.aggregate": "server", + }), [WS_METHODS.serverGetProcessResourceHistory]: (input) => observeRpcEffect( WS_METHODS.serverGetProcessResourceHistory, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 07407bcf21b3..7ad86106ae08 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -6,6 +6,7 @@ import { FolderGitIcon, FolderIcon, HistoryIcon, + ScaleIcon, } from "lucide-react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -56,6 +57,8 @@ interface BranchToolbarProps { onActiveThreadBranchOverrideChange?: (branch: string | null) => void; startFromOrigin: boolean; onStartFromOriginChange: (startFromOrigin: boolean) => void; + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; @@ -66,6 +69,8 @@ interface BranchToolbarProps { } interface MobileRunContextSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; envModeLocked: boolean; environmentId: EnvironmentId; @@ -81,6 +86,8 @@ interface MobileRunContextSelectorProps { } const MobileRunContextSelector = memo(function MobileRunContextSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, envModeLocked, environmentId, @@ -114,10 +121,14 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ // Button's base styles apply `-mx-0.5` to descendant SVGs, which eats 4px // out of whatever gap we set. mx-0! cancels that so gap-0.5 reads as 2px. - + {autoEnvironmentLabel ? ( + ) : ( @@ -134,7 +145,8 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ data-composer-label-motion className="block w-full min-w-0 max-w-[240px] origin-left truncate transition-[opacity,transform] duration-180 ease-[cubic-bezier(0.32,0.72,0,1)] group-data-[compact]/composer-context:[transform:translateX(-0.25rem)_scaleX(0.95)] group-data-[compact]/composer-context:opacity-0 motion-reduce:transform-none motion-reduce:transition-opacity" > - {showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel} + {autoEnvironmentLabel ?? + (showEnvironmentIndicator ? (activeEnvironment?.label ?? "Run on") : workspaceLabel)} @@ -167,9 +179,29 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ Run on onEnvironmentChange(value as EnvironmentId)} + value={autoEnvironmentLabel ? "auto" : environmentId} + onValueChange={(value) => + value === "auto" + ? onAutoEnvironment?.() + : onEnvironmentChange(value as EnvironmentId) + } > + {onAutoEnvironment && ( + { + if (autoEnvironmentLabel) onAutoEnvironment?.(); + }} + > + + + + )} {availableEnvironments.map((env) => ( { + (branch: string | null, worktreePath: string | null, automatic = false) => { if (!activeThreadId || !activeProject) return; if (serverSession && worktreePath !== activeWorktreePath) { void stopThreadSession({ @@ -186,6 +186,7 @@ export function BranchToolbarBranchSelector({ branch, worktreePath, envMode: nextDraftEnvMode, + environmentSelection: automatic ? (draftThread?.environmentSelection ?? "auto") : "manual", projectRef: scopeProjectRef(environmentId, activeProject.id), }); }, @@ -201,6 +202,7 @@ export function BranchToolbarBranchSelector({ threadRef, environmentId, effectiveEnvMode, + draftThread?.environmentSelection, stopThreadSession, updateThreadMetadata, ], @@ -507,7 +509,7 @@ export function BranchToolbarBranchSelector({ ) { return; } - setThreadBranch(worktreeBaseBranchCandidate, null); + setThreadBranch(worktreeBaseBranchCandidate, null, true); }, [ activeThreadBranch, activeWorktreePath, diff --git a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx index 431805f2174b..863cc7312fe6 100644 --- a/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvironmentSelector.tsx @@ -1,4 +1,5 @@ import type { EnvironmentId } from "@t3tools/contracts"; +import { ScaleIcon } from "lucide-react"; import { memo, useMemo } from "react"; import type { EnvironmentOption } from "./BranchToolbar.logic"; @@ -15,6 +16,8 @@ import { } from "./ui/select"; interface BranchToolbarEnvironmentSelectorProps { + autoEnvironmentLabel?: string | undefined; + onAutoEnvironment?: (() => void) | undefined; envLocked: boolean; environmentId: EnvironmentId; availableEnvironments: readonly EnvironmentOption[]; @@ -24,6 +27,8 @@ interface BranchToolbarEnvironmentSelectorProps { } export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvironmentSelector({ + autoEnvironmentLabel, + onAutoEnvironment, envLocked, environmentId, availableEnvironments, @@ -34,12 +39,16 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir }, [availableEnvironments, environmentId]); const environmentItems = useMemo( - () => - availableEnvironments.map((env) => ({ + () => [ + ...(onAutoEnvironment + ? [{ value: "auto", label: autoEnvironmentLabel ?? "Auto balance" }] + : []), + ...availableEnvironments.map((env) => ({ value: env.environmentId, label: env.label, })), - [availableEnvironments], + ], + [availableEnvironments, autoEnvironmentLabel, onAutoEnvironment], ); // The static label carries the xs control's height (h-7 sm:h-6) as well as @@ -75,8 +84,10 @@ export const BranchToolbarEnvironmentSelector = memo(function BranchToolbarEnvir return ( + } + /> + ); + })} + + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index f715f6ca4e6d..dd4e357eb9d7 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -514,6 +514,14 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/connections", searchTerms: ["add pair backend host code ssh config agent tunnel saved t3 connect"], }, + { + id: "load-balancing", + title: "Load balancing", + to: "/settings/connections", + searchTerms: [ + "automatic machine environment resources cpu memory capacity preference weight shared projects", + ], + }, { id: "archive", title: "Archived threads", diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 53d07aab21d9..4a682b97910d 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -1623,6 +1623,63 @@ describe("composerDraftStore project draft thread mapping", () => { expect(file && composerFileNeedsReattach(file)).toBe(true); }); + it("rechecks balancing when an empty draft is remapped to another project member", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { + threadId, + environmentSelection: "auto", + loadBalancedEnvironmentId: TEST_ENVIRONMENT_ID, + }); + store.setProjectDraftThreadId(remoteProjectRef, draftId, { threadId }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + store.setDraftThreadContext(draftId, { loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID }); + store.setDraftThreadContext(draftId, { projectRef }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + }); + + it("does not opt a legacy branch choice into balancing when runtime mode changes", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId, branch: "feature/pinned" }); + store.setDraftThreadContext(draftId, { runtimeMode: "full-access" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBeUndefined(); + expect(store.getDraftThread(draftId)?.branch).toBe("feature/pinned"); + }); + + it("pins manual workspace choices and can return to automatic routing without losing the prompt", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectRef, draftId, { threadId }); + store.setPrompt(draftId, "keep this prompt"); + store.setDraftThreadContext(draftId, { + projectRef: remoteProjectRef, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + environmentId: OTHER_TEST_ENVIRONMENT_ID, + environmentSelection: "auto", + loadBalancedEnvironmentId: OTHER_TEST_ENVIRONMENT_ID, + }); + store.setDraftThreadContext(draftId, { branch: "feature/pinned" }); + expect(store.getDraftThread(draftId)?.environmentSelection).toBe("manual"); + store.setDraftThreadContext(draftId, { + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getDraftThread(draftId)).toMatchObject({ + branch: null, + environmentSelection: "auto", + loadBalancedEnvironmentId: null, + }); + expect(store.getComposerDraft(draftId)?.prompt).toBe("keep this prompt"); + }); + it("clears branch and worktree but keeps env mode when changing a draft thread project ref", () => { const store = useComposerDraftStore.getState(); store.setProjectDraftThreadId(projectRef, draftId, { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 9dbc7b9b5c21..5bdd823b4d88 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -315,6 +315,8 @@ const PersistedDraftThreadState = Schema.Struct({ environmentId: Schema.String, projectId: ProjectId, logicalProjectKey: Schema.optionalKey(Schema.String), + environmentSelection: Schema.optionalKey(Schema.Literals(["auto", "manual"])), + loadBalancedEnvironmentId: Schema.optionalKey(Schema.NullOr(Schema.String)), createdAt: Schema.String, runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, @@ -427,6 +429,8 @@ export interface DraftSessionState { environmentId: EnvironmentId; projectId: ProjectId; logicalProjectKey: string; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; createdAt: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; @@ -503,6 +507,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Creates or updates the draft session tracked for a concrete project ref. */ @@ -518,6 +524,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; /** Updates mutable draft-session metadata without touching composer content. */ @@ -532,6 +540,8 @@ interface ComposerDraftStoreState { startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ) => void; clearProjectDraftThreadId: (projectRef: ScopedProjectRef) => void; @@ -1534,6 +1544,8 @@ function createDraftThreadState( startFromOrigin?: boolean; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; + environmentSelection?: "auto" | "manual"; + loadBalancedEnvironmentId?: EnvironmentId | null; }, ): DraftThreadState { // A project change (including switching environments within a logical @@ -1560,11 +1572,23 @@ function createDraftThreadState( options?.startFromOrigin === undefined ? (existingThread?.startFromOrigin ?? false) : options.startFromOrigin; + const environmentSelection = + options?.environmentSelection ?? existingThread?.environmentSelection; return { threadId, environmentId: projectRef.environmentId, projectId: projectRef.projectId, logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + ...(options?.loadBalancedEnvironmentId !== undefined + ? { loadBalancedEnvironmentId: options.loadBalancedEnvironmentId } + : existingThread?.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: projectChanged + ? null + : existingThread.loadBalancedEnvironmentId, + } + : {}), createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: @@ -1599,6 +1623,8 @@ function draftThreadsEqual(left: DraftThreadState | undefined, right: DraftThrea left.environmentId === right.environmentId && left.projectId === right.projectId && left.logicalProjectKey === right.logicalProjectKey && + left.environmentSelection === right.environmentSelection && + left.loadBalancedEnvironmentId === right.loadBalancedEnvironmentId && left.createdAt === right.createdAt && left.runtimeMode === right.runtimeMode && left.interactionMode === right.interactionMode && @@ -1754,6 +1780,16 @@ function normalizePersistedDraftThreads( worktreePath: normalizedWorktreePath, envMode: normalizeDraftThreadEnvMode(candidateDraftThread.envMode, normalizedWorktreePath), startFromOrigin, + ...(candidateDraftThread.environmentSelection === "manual" || + candidateDraftThread.environmentSelection === "auto" + ? { environmentSelection: candidateDraftThread.environmentSelection } + : {}), + ...(typeof candidateDraftThread.loadBalancedEnvironmentId === "string" && + candidateDraftThread.loadBalancedEnvironmentId.length > 0 + ? { loadBalancedEnvironmentId: candidateDraftThread.loadBalancedEnvironmentId } + : candidateDraftThread.loadBalancedEnvironmentId === null + ? { loadBalancedEnvironmentId: null } + : {}), promotedTo, }; } @@ -2453,6 +2489,15 @@ function toHydratedDraftThreadState( worktreePath: persistedDraftThread.worktreePath, envMode: persistedDraftThread.envMode, startFromOrigin: persistedDraftThread.startFromOrigin, + ...(persistedDraftThread.environmentSelection + ? { environmentSelection: persistedDraftThread.environmentSelection } + : {}), + ...(persistedDraftThread.loadBalancedEnvironmentId !== undefined + ? { + loadBalancedEnvironmentId: + persistedDraftThread.loadBalancedEnvironmentId as EnvironmentId | null, + } + : {}), promotedTo: persistedDraftThread.promotedTo ? scopeThreadRef( persistedDraftThread.promotedTo.environmentId as EnvironmentId, @@ -2707,11 +2752,23 @@ const composerDraftStore = create()( options.startFromOrigin === undefined ? existing.startFromOrigin : options.startFromOrigin; + const environmentSelection = + options.environmentSelection ?? + (options.branch != null || options.worktreePath != null + ? "manual" + : existing.environmentSelection); const nextDraftThread: DraftThreadState = { threadId: existing.threadId, environmentId: nextProjectRef.environmentId, projectId: nextProjectRef.projectId, logicalProjectKey: existing.logicalProjectKey, + ...(environmentSelection ? { environmentSelection } : {}), + loadBalancedEnvironmentId: + options.loadBalancedEnvironmentId === undefined + ? projectChanged + ? null + : (existing.loadBalancedEnvironmentId ?? null) + : options.loadBalancedEnvironmentId, createdAt: options.createdAt === undefined ? existing.createdAt @@ -2729,6 +2786,8 @@ const composerDraftStore = create()( nextDraftThread.environmentId === existing.environmentId && nextDraftThread.projectId === existing.projectId && nextDraftThread.logicalProjectKey === existing.logicalProjectKey && + nextDraftThread.environmentSelection === existing.environmentSelection && + nextDraftThread.loadBalancedEnvironmentId === existing.loadBalancedEnvironmentId && nextDraftThread.createdAt === existing.createdAt && nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.interactionMode === existing.interactionMode && diff --git a/apps/web/src/hooks/useLoadBalancedEnvironment.ts b/apps/web/src/hooks/useLoadBalancedEnvironment.ts new file mode 100644 index 000000000000..5d3ddfb8b1ef --- /dev/null +++ b/apps/web/src/hooks/useLoadBalancedEnvironment.ts @@ -0,0 +1,50 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import { chooseLoadBalancedEnvironment } from "@t3tools/client-runtime/load-balancing"; +import type { EnvironmentId } from "@t3tools/contracts"; +import { Atom } from "effect/unstable/reactivity"; +import { useCallback, useContext, useMemo } from "react"; + +import { serverEnvironment } from "../state/server"; + +/** Only mounted for unresolved automatic drafts, so idle clients do not poll hosts. */ +export function useLoadBalancedEnvironment( + environmentIds: readonly EnvironmentId[], + weights: Readonly>, +) { + const registry = useContext(RegistryContext); + const refresh = useCallback( + (ids: readonly EnvironmentId[]) => { + for (const environmentId of ids) { + registry.refresh(serverEnvironment.hostResources({ environmentId, input: {} })); + } + }, + [registry], + ); + const resourcesAtom = useMemo( + () => + Atom.make((get) => + environmentIds.map((environmentId) => { + const result = get(serverEnvironment.hostResources({ environmentId, input: {} })); + return { + environmentId, + resources: result._tag === "Success" ? result.value : null, + receivedAt: result._tag === "Success" ? result.timestamp : 0, + pending: result._tag === "Initial" || result.waiting, + }; + }), + ), + [environmentIds], + ); + const resources = useAtomValue(resourcesAtom); + return { + refresh, + pending: resources.some((resource) => resource.pending), + environmentId: chooseLoadBalancedEnvironment( + resources.map((resource) => ({ + ...resource, + weight: weights[resource.environmentId] ?? 50, + })), + Date.now(), + ) as EnvironmentId | null, + }; +} diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index ec2724b4b04e..b10123ab6223 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -61,6 +61,23 @@ created in Settings can only be copied from the client that created them while its Connections page stays open. If you leave or reload that page, create another link to share. +### Balance new threads across machines + +Auto balance is off by default. On web and desktop, enable it in +**Settings → Connections → Load balancing** to automatically choose a machine for +new threads in projects grouped across connected environments. +Each machine starts at **Normal**. Choose **Prefer** to favor it when it has CPU and +memory available, **Less often** to reduce its share, or **Manual only** to exclude +it from automatic selection. These are preferences, not fixed traffic percentages. +Preferences are saved separately in each client. + +The composer checks eligible machines when choosing a draft's environment, then keeps +that choice stable. Choose **Auto balance** again to check current resources, or choose +a specific machine to override it. Choosing a branch or worktree also keeps the draft +on that machine. Existing threads stay where they started. If resource checks are +unavailable or all eligible machines are full, choose a machine manually to continue. +Mobile keeps its manual environment selection. + ### Tailscale HTTPS Join both devices to the same tailnet. In the desktop app, enable **Tailscale diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 7409a194a2fd..775a4a898f8f 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -3,6 +3,10 @@ "private": true, "type": "module", "exports": { + "./load-balancing": { + "types": "./src/load-balancing.ts", + "default": "./src/load-balancing.ts" + }, "./project-favicon-cache": { "types": "./src/projectFaviconCache.ts", "default": "./src/projectFaviconCache.ts" diff --git a/packages/client-runtime/src/load-balancing.ts b/packages/client-runtime/src/load-balancing.ts new file mode 100644 index 000000000000..0b938c6c090e --- /dev/null +++ b/packages/client-runtime/src/load-balancing.ts @@ -0,0 +1,40 @@ +import type { HostResourcesSnapshot } from "@t3tools/contracts"; + +/** Callers supply only connected machines hosting the project and selected provider. */ +export function chooseLoadBalancedEnvironment( + candidates: ReadonlyArray<{ + environmentId: string; + resources: HostResourcesSnapshot | null; + /** Client receipt time avoids comparing clocks on different machines. */ + receivedAt?: number; + weight: number; + }>, + now: number, +): string | null { + let selected: string | null = null; + let bestScore = 0; + for (const { environmentId, resources, receivedAt, weight } of candidates) { + const sampledAt = receivedAt ?? resources?.sampledAt ?? 0; + if ( + !resources || + !Number.isFinite(weight) || + weight <= 0 || + now - sampledAt > 15_000 || + sampledAt > now + 5_000 || + resources.cpuUtilization === null || + resources.cpuUtilization >= 0.95 || + resources.totalMemoryBytes <= 0 || + resources.cpuCount <= 0 + ) { + continue; + } + const memoryAvailable = resources.availableMemoryBytes / resources.totalMemoryBytes; + if (memoryAvailable <= 0.05) continue; + const score = weight * resources.cpuCount * (1 - resources.cpuUtilization) * memoryAvailable; + if (score > bestScore) { + selected = environmentId; + bestScore = score; + } + } + return selected; +} diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 94d213b257b6..4884c3b99bbc 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -2,6 +2,7 @@ import { EnvironmentId, ProjectId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import type { EnvironmentProject } from "./models.ts"; +import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, derivePhysicalProjectKey, @@ -9,6 +10,70 @@ import { } from "./projectGrouping.ts"; const environmentId = EnvironmentId.make("environment"); + +describe("load balancing shared project machines", () => { + const now = 100_000; + const resources = { + sampledAt: now, + cpuUtilization: 0.2, + cpuCount: 8, + availableMemoryBytes: 8_000, + totalMemoryBytes: 16_000, + }; + + it("compares three machines using free capacity and preference", () => { + const candidates = [ + { environmentId: "busy", resources: { ...resources, cpuUtilization: 0.9 }, weight: 1 }, + { environmentId: "idle", resources, weight: 1 }, + { environmentId: "preferred", resources: { ...resources, cpuCount: 4 }, weight: 3 }, + ]; + expect(chooseLoadBalancedEnvironment(candidates, now)).toBe("preferred"); + expect(chooseLoadBalancedEnvironment(candidates.slice(0, 2), now)).toBe("idle"); + }); + + it("rejects stale, unknown, excluded and saturated machines", () => { + expect( + chooseLoadBalancedEnvironment( + [ + { + environmentId: "stale", + resources: { ...resources, sampledAt: now - 15_001 }, + weight: 1, + }, + { environmentId: "unknown", resources: null, weight: 1 }, + { + environmentId: "no-cpu-sample", + resources: { ...resources, cpuUtilization: null }, + weight: 1, + }, + { environmentId: "excluded", resources, weight: 0 }, + { + environmentId: "cpu-full", + resources: { ...resources, cpuUtilization: 0.95 }, + weight: 1, + }, + { + environmentId: "memory-full", + resources: { ...resources, availableMemoryBytes: 100 }, + weight: 1, + }, + ], + now, + ), + ).toBeNull(); + }); + + it("uses client receipt time when host clocks differ", () => { + const candidate = { + environmentId: "different-clock", + resources: { ...resources, sampledAt: now + 60_000 }, + receivedAt: now, + weight: 1, + }; + expect(chooseLoadBalancedEnvironment([candidate], now)).toBe("different-clock"); + expect(chooseLoadBalancedEnvironment([candidate], now + 15_001)).toBeNull(); + }); +}); const repositoryIdentity = { canonicalKey: "github.com/t3tools/t3code", locator: { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 5df29a8629d4..911ee1bd85c6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -26,6 +26,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { createAtomCommandScheduler, createEnvironmentRpcCommand, + createEnvironmentQueryAtomFamily, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, createRuntimeCommand, @@ -1018,6 +1019,12 @@ export function createServerEnvironmentAtoms( label: "environment-data:server:process-diagnostics", tag: WS_METHODS.serverGetProcessDiagnostics, }), + hostResources: createEnvironmentQueryAtomFamily(runtime, { + label: "environment-data:server:host-resources", + staleTimeMs: 5_000, + execute: (input: EnvironmentRpcInput) => + request(WS_METHODS.serverGetHostResources, input).pipe(Effect.timeout("5 seconds")), + }), processResourceHistory: createEnvironmentRpcQueryAtomFamily(runtime, { label: "environment-data:server:process-resource-history", tag: WS_METHODS.serverGetProcessResourceHistory, diff --git a/packages/contracts/src/resourceTelemetry.ts b/packages/contracts/src/resourceTelemetry.ts index 3ec1e4de3ef4..87b52993a56b 100644 --- a/packages/contracts/src/resourceTelemetry.ts +++ b/packages/contracts/src/resourceTelemetry.ts @@ -6,6 +6,16 @@ import { DesktopUpdateStateSchema } from "./ipc.ts"; export const RESOURCE_MONITOR_PROTOCOL_VERSION = 2 as const; +/** Whole-host capacity, independent of T3's process diagnostics. */ +export const HostResourcesSnapshot = Schema.Struct({ + sampledAt: NonNegativeInt, + cpuUtilization: Schema.NullOr(Schema.Number.check(Schema.isBetween({ minimum: 0, maximum: 1 }))), + cpuCount: NonNegativeInt, + availableMemoryBytes: NonNegativeInt, + totalMemoryBytes: NonNegativeInt, +}); +export type HostResourcesSnapshot = typeof HostResourcesSnapshot.Type; + export const ResourceTelemetryIoSemantics = Schema.Literals([ "storage", "logical", diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 12c653f70cc4..fb077193c202 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -210,6 +210,7 @@ import { ServerUpsertKeybindingResult, } from "./server.ts"; import { + HostResourcesSnapshot, ResourceTelemetryHistory, ResourceTelemetryHistoryInput, ResourceTelemetryRetryResult, @@ -323,6 +324,7 @@ export const WS_METHODS = { serverDiscoverSourceControl: "server.discoverSourceControl", serverGetTraceDiagnostics: "server.getTraceDiagnostics", serverGetProcessDiagnostics: "server.getProcessDiagnostics", + serverGetHostResources: "server.getHostResources", serverGetProcessResourceHistory: "server.getProcessResourceHistory", serverGetResourceTelemetryHistory: "server.getResourceTelemetryHistory", serverRetryResourceTelemetry: "server.retryResourceTelemetry", @@ -538,6 +540,12 @@ const WsServerGetProcessDiagnosticsRpc = Rpc.make(WS_METHODS.serverGetProcessDia error: EnvironmentAuthorizationError, }); +const WsServerGetHostResourcesRpc = Rpc.make(WS_METHODS.serverGetHostResources, { + payload: Schema.Struct({}), + success: HostResourcesSnapshot, + error: EnvironmentAuthorizationError, +}); + const WsServerGetProcessResourceHistoryRpc = Rpc.make(WS_METHODS.serverGetProcessResourceHistory, { payload: ServerProcessResourceHistoryInput, success: ServerProcessResourceHistoryResult, @@ -1197,6 +1205,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, + WsServerGetHostResourcesRpc, WsServerGetProcessResourceHistoryRpc, WsServerGetResourceTelemetryHistoryRpc, WsServerRetryResourceTelemetryRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 1673f4ad159b..7c497bacfa89 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -135,6 +135,21 @@ describe("ClaudeSettings auto-compaction", () => { }); }); +describe("ClientSettings load balancing", () => { + it("requires opt-in when settings are new or omit load balancing", () => { + expect(decodeClientSettings({}).loadBalancingEnabled).toBe(false); + expect(decodeClientSettings({ loadBalancingWeights: {} }).loadBalancingEnabled).toBe(false); + }); + + it.each([true, false])("preserves a saved choice of %s", (loadBalancingEnabled) => { + const settings = decodeClientSettings({ loadBalancingEnabled }); + expect(encodeClientSettings(settings).loadBalancingEnabled).toBe(loadBalancingEnabled); + expect(decodeClientSettingsPatch({ loadBalancingEnabled }).loadBalancingEnabled).toBe( + loadBalancingEnabled, + ); + }); +}); + describe("ClientSettings word wrap", () => { it("defaults word wrap on", () => { expect(decodeClientSettings({}).wordWrap).toBe(true); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 983e17b54370..1ddf66cde63c 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -204,7 +204,14 @@ export const BrowserLinkTarget = Schema.Literals(["system", "app"]); export type BrowserLinkTarget = typeof BrowserLinkTarget.Type; export const DEFAULT_BROWSER_LINK_TARGET: BrowserLinkTarget = "system"; +export const LoadBalancingWeights = Schema.Record( + TrimmedNonEmptyString, + Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: 100 })), +); + export const ClientSettingsSchema = Schema.Struct({ + loadBalancingEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + loadBalancingWeights: LoadBalancingWeights.pipe(Schema.withDecodingDefault(Effect.succeed({}))), appearanceContrast: AppearanceContrast.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_APPEARANCE_CONTRAST)), ), @@ -1210,6 +1217,8 @@ export const ServerSettingsPatch = Schema.Struct({ export type ServerSettingsPatch = typeof ServerSettingsPatch.Type; export const ClientSettingsPatch = Schema.Struct({ + loadBalancingEnabled: Schema.optionalKey(Schema.Boolean), + loadBalancingWeights: Schema.optionalKey(LoadBalancingWeights), appearanceContrast: Schema.optionalKey(AppearanceContrast), panelAnimationDurationMs: Schema.optionalKey(PanelAnimationDurationMs), browserDefaultViewport: Schema.optionalKey(PreviewViewportSetting), From f1e84c28fe5982ecb507f4c934acf7a1a7b253b7 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 22:33:29 -0700 Subject: [PATCH 190/320] test(web): keep settings viewport comparison private (#10307) --- .../settings/SettingsPanels.logic.test.ts | 23 ------------------- .../settings/SettingsPanels.logic.ts | 2 +- 2 files changed, 1 insertion(+), 24 deletions(-) diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index b99c69ee331f..d93db8d970b1 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -14,7 +14,6 @@ import { formatDiagnosticsDescription, getChangedBrowserSettingLabels, getChangedTypographySettingLabels, - isSamePreviewViewport, hasChangedBackgroundActivitySettings, isProjectGroupingEnabled, projectGroupingModeFromToggle, @@ -282,25 +281,3 @@ describe("getChangedBrowserSettingLabels", () => { ]); }); }); - -describe("isSamePreviewViewport", () => { - it("separates presets that share a size", () => { - // Two presets can agree on width and height and still be different - // entries in the picker, so the id has to take part in the comparison. - expect( - isSamePreviewViewport( - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - { _tag: "preset", width: 390, height: 844, presetId: "ipad-mini" }, - ), - ).toBe(false); - }); - - it("separates a freeform viewport from a preset of the same size", () => { - expect( - isSamePreviewViewport( - { _tag: "freeform", width: 390, height: 844 }, - { _tag: "preset", width: 390, height: 844, presetId: "iphone-12-pro" }, - ), - ).toBe(false); - }); -}); diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 3ac6bbaa0017..5cbcb190a97b 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -126,7 +126,7 @@ export type BrowserDefaultSettings = Pick< * reports every stored viewport as changed — including one that matches the * default. */ -export function isSamePreviewViewport( +function isSamePreviewViewport( left: PreviewViewportSetting, right: PreviewViewportSetting, ): boolean { From 4f782bedaf49b914903eb08501f35c0940845c51 Mon Sep 17 00:00:00 2001 From: Guilherme Vieira <46866023+GuilhermeVieiraDev@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:37:07 +0100 Subject: [PATCH 191/320] fix(web): prevent file tree search focus ring clipping (#10175) --- apps/web/src/components/files/FileBrowserPanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 7ed900963b49..49894db3c8cf 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -373,7 +373,7 @@ export default function FileBrowserPanel({ data-file-browser-panel={`${environmentId}:${cwd}`} >
From b7465a3bc993e7f10f4ec7a469759b95eb4c2f2a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:04:16 -0700 Subject: [PATCH 192/320] fix(mobile): stop the work log flickering during subagent runs and failing calls (#10273) --- .../src/features/threads/ThreadFeed.tsx | 33 +- .../src/features/threads/thread-work-log.tsx | 195 ++++++++-- apps/mobile/src/lib/threadActivity.test.ts | 334 +++++++++++++++++- apps/mobile/src/lib/threadActivity.ts | 214 ++++++++++- 4 files changed, 724 insertions(+), 52 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index b57758b50c12..9b09ef6d903d 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -153,6 +153,7 @@ import { } from "./thread-feed-live-follow"; import { collapsedWorkLogHeight, + ThreadAgentSpawnCard, ThreadDisclosureChevron, ThreadWorkGroupToggle, ThreadThinkingRow, @@ -504,12 +505,7 @@ function MessageAttachmentFile(props: { function MessageAttachmentUnknown(props: { readonly name: string }) { return ( - + {props.name} @@ -1357,7 +1353,7 @@ function renderFeedEntry( accessibilityState={{ expanded: entry.expanded }} onPress={() => props.onToggleTurnFold(entry.turnId)} hitSlop={4} - className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-border px-2" + className="mb-1 min-h-11 flex-row items-center gap-2 border-b border-adaptive-neutral-200-a80-white-a8 px-2" style={{ minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), }} @@ -1382,6 +1378,19 @@ function renderFeedEntry( return ; } + if (entry.type === "agent-spawn") { + return ( + props.onToggleWorkGroup(entry.id, entry.id)} + onCopy={() => props.onCopyWorkRow(entry.activity.id, entry.activity.getCopyText())} + /> + ); + } + if (entry.type === "work-toggle") { return ( - + {label} - + ); } @@ -1508,7 +1517,7 @@ function renderFeedEntry( })} - + {timestampLabel} {message.text.trim().length > 0 ? ( @@ -1557,7 +1566,7 @@ function renderFeedEntry( attachmentId={attachment.id} name={attachment.name} mimeType={attachment.mimeType} - className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-subtle-strong" + className="mt-1.5 aspect-[1.3] w-full rounded-[18px] bg-adaptive-neutral-200-800" onPressPreview={props.onPressPreview} /> ) : isFileAttachment(attachment) ? ( @@ -1581,7 +1590,7 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - + {timestampLabel} diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 9d5b40fce6dc..e812e77ef088 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -34,7 +34,11 @@ import { AppText as Text } from "../../components/AppText"; import { T3Wordmark } from "../../components/T3Wordmark"; import { cn } from "../../lib/cn"; import { THREAD_WORK_ROW_MIN_HEIGHT, type deriveThreadWorkLogSizing } from "../../lib/layout"; -import { type ThreadFeedActivity, workEntryRowLabel } from "../../lib/threadActivity"; +import { + type AgentSpawnSummary, + type ThreadFeedActivity, + workEntryRowLabel, +} from "../../lib/threadActivity"; import { resolveThreadWorkGroupInitialScroll, shouldFollowThreadWorkGroupAppend, @@ -135,6 +139,7 @@ export function ThreadDisclosureChevron(props: { } function ShimmerWorkContent(props: { + readonly compact?: boolean; readonly environmentId?: EnvironmentId; readonly highlighted: boolean; readonly icon: WorkContentIcon; @@ -147,26 +152,29 @@ function ShimmerWorkContent(props: { }) { return ( - - {props.showIcon && props.toolIcon && props.environmentId ? ( - - ) : props.showIcon ? ( - - ) : null} - + {props.showIcon ? ( + + {props.toolIcon && props.environmentId ? ( + + ) : ( + + )} + + ) : null} { const subscription = AppState.addEventListener("change", (state) => { @@ -250,6 +263,7 @@ export function ShimmeringWorkContent(props: { onLayout={(event) => setAvailableWidth(event.nativeEvent.layout.width)} > @@ -832,7 +847,7 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( entering={WORK_LOG_DETAIL_ENTER_TRANSITION} exiting={WORK_LOG_DETAIL_EXIT_TRANSITION} layout={WORK_LOG_LAYOUT_TRANSITION} - className="ml-7 border-l border-border pb-1 pl-3 pt-0.5" + className="ml-7 border-l border-adaptive-neutral-300-a60-white-a12 pb-1 pl-3 pt-0.5" > {viewedImagePath ? ( @@ -938,6 +953,140 @@ export function ThreadWorkGroupToggle(props: { ); } +const AGENT_SPAWN_TONE_DOT_CLASS = { + working: "bg-adaptive-sky-600-400", + completed: "bg-adaptive-emerald-600-400", + failed: "bg-adaptive-rose-600-400", + stopped: "bg-foreground-muted", +} as const satisfies Record; + +/** + * A batch of spawned subagents. The status line updates in place as members + * report progress; expanding lists each member. Text nodes carry keys tied to + * the row identity only, so a progress tick re-renders the labels without + * remounting the card (see the batch key in appendActivityGroupRows). + */ +export const ThreadAgentSpawnCard = memo(function ThreadAgentSpawnCard(props: { + readonly summary: AgentSpawnSummary; + readonly expanded: boolean; + readonly iconSubtleColor: ColorValue; + readonly rowSizing: ReturnType; + readonly onToggle: () => void; + readonly onCopy: () => void; +}) { + const { summary, expanded } = props; + const working = summary.tone === "working"; + const memberCount = summary.members.length; + const canExpand = memberCount > 0; + return ( + + { + if (!canExpand) return; + void Haptics.selectionAsync(); + props.onToggle(); + }} + onLongPress={props.onCopy} + className="rounded-xl border border-adaptive-neutral-200-a80-white-a8 bg-card px-2.5 py-2 active:bg-subtle" + > + + + + + + + {summary.title} + + + + {working ? ( + + ) : ( + + {summary.status} + + )} + + + {canExpand ? ( + + ) : null} + + {expanded && canExpand ? ( + + {summary.members.map((member) => ( + + + + + {member.title} + + {member.status} + + {member.detail ? ( + + {member.detail} + + ) : null} + + ))} + + ) : null} + + + ); +}); + export function ThreadThinkingRow(props: { readonly rowSizing: ReturnType; readonly iconSubtleColor: ColorValue; diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 6131dc9a1b31..7d6cc39ea616 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -13,6 +13,7 @@ import { } from "@t3tools/contracts"; import { + agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, derivePendingApprovals, @@ -24,6 +25,7 @@ import { workEntryRowLabel, type ThreadFeedActivity, type ThreadFeedEntry, + type WorkLogEntry, } from "./threadActivity"; describe("Codex feedback pseudo-messages", () => { @@ -2303,10 +2305,12 @@ describe("buildThreadFeed", () => { new Set(), latestTurn.startedAt, ); + // The shimmering row is the turn's live slot; once it stops shimmering + // the slot belongs to "Thinking" and the group keeps its own identity. expect(rows.slice(0, 3).map((entry) => [entry.id, entry.type])).toEqual([ ["work-toggle:work-group:activity-1", "work-toggle"], ["activity-2", "activity-group"], - ["work-live:work-group:activity-3", "work-toggle"], + [shimmer ? "live-activity-row" : "work-live:work-group:activity-3", "work-toggle"], ]); expect(rows.slice(0, 3).map((entry) => entry.type === "work-toggle" && entry.live)).toEqual([ false, @@ -2381,7 +2385,7 @@ describe("buildThreadFeed", () => { const rows = deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now"); expect(rows.map((entry) => entry.type)).toEqual(["message", "thinking"]); - expect(rows[1]).toMatchObject({ id: "thinking", createdAt: "now", turnId }); + expect(rows[1]).toMatchObject({ id: "live-activity-row", createdAt: "now", turnId }); // The row identity is stable across re-derivations so the list can reuse it. expect(deriveThreadFeedPresentation(feed, latestTurn, new Set(), new Set(), "now")[1]).toBe( rows[1], @@ -2394,6 +2398,79 @@ describe("buildThreadFeed", () => { ).toEqual(["message"]); }); + it("keeps one live slot while calls fail and restart", () => { + // Recorded from a Claude session whose Bash was broken: every call went + // inProgress → failed within two seconds. Each transition used to insert + // or remove a Thinking row under the group; now the same row id holds + // the live call and then "Thinking", so the list updates it in place. + const turnId = TurnId.make("turn-failing-calls"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const call = (n: number, status: "inProgress" | "failed") => + makeActivity({ + id: EventId.make(`call-${n}-${status}`), + kind: status === "failed" ? "tool.completed" : "tool.updated", + tone: "tool", + summary: "Command run", + createdAt: `2026-04-01T00:00:${String(n * 2 + (status === "failed" ? 1 : 0)).padStart(2, "0")}.000Z`, + turnId, + payload: { + itemType: "command_execution", + toolCallId: `call-${n}`, + title: "Command run", + status, + detail: `Bash: ls ${n}`, + }, + }); + const liveIds = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-failing-calls"), + projectId: ProjectId.make("project-1"), + title: "Failing calls", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ).map((row) => `${row.type}:${row.id}`); + + expect(liveIds([call(1, "inProgress")])).toEqual(["work-toggle:live-activity-row"]); + expect(liveIds([call(1, "inProgress"), call(1, "failed")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "thinking:live-activity-row", + ]); + expect(liveIds([call(1, "inProgress"), call(1, "failed"), call(2, "inProgress")])).toEqual([ + "work-toggle:live-activity-row", + ]); + // A call whose end was never reported, in a run before an error row, + // keeps its own identity: only the trailing run can hold the live slot. + const errorRow = makeActivity({ + id: EventId.make("runtime-error"), + kind: "runtime.error", + tone: "error", + summary: "Provider error", + createdAt: "2026-04-01T00:00:02.500Z", + turnId, + payload: { message: "boom" }, + }); + expect(liveIds([call(1, "inProgress"), errorRow, call(2, "inProgress")])).toEqual([ + "work-toggle:work-live:work-group:tool:turn-failing-calls:call-1", + "activity-group:runtime-error", + "work-toggle:live-activity-row", + ]); + }); + it("hands a settled tool run off to Thinking once assistant text streams after it", () => { const turnId = TurnId.make("turn-streaming-tail"); const latestTurn = { @@ -2813,19 +2890,22 @@ describe("quiet timeline: nested agents", () => { }), ).flatMap((entry) => (entry.type === "activity-group" ? entry.activities : [])); + // The batch anchors on the first task.started: a fixed id and timestamp, + // unlike progress ticks (which the server rewrites in place). const running = rowsFor([]); expect(running.map((row) => [row.id, row.summary])).toEqual([ - ["a-progress", "Kicked off 2 subagents · 2 working"], + ["a-start", "Kicked off 2 subagents · 2 working"], ["shell-1", "Run tests"], ]); expect(running[0]).toMatchObject({ + createdAt: "2026-04-01T00:00:01.000Z", lifecycleStatus: "inProgress", workEntry: { agentSpawn: { agentTaskIds: ["a", "b"] } }, }); const oneDone = rowsFor([agent("a-done", "task.completed", "a", "completed", 7)]); expect(oneDone[0]).toMatchObject({ - id: "a-progress", + id: "a-start", summary: "Kicked off 2 subagents · 1 working", lifecycleStatus: "inProgress", }); @@ -2835,7 +2915,7 @@ describe("quiet timeline: nested agents", () => { agent("b-failed", "task.updated", "b", "failed", 8, { error: "boom" }), ]); expect(allDone[0]).toMatchObject({ - id: "a-progress", + id: "a-start", summary: "Ran 2 subagents · 1 failed", lifecycleStatus: "failed", status: "failure", @@ -2843,6 +2923,194 @@ describe("quiet timeline: nested agents", () => { expect(allDone).toHaveLength(2); }); + it("folds the tool call that launched an agent into its spawn card", () => { + const turnId = TurnId.make("turn-agent-tool"); + const at = (seconds: number) => `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`; + const feed = buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-agent-tool"), + projectId: ProjectId.make("project-1"), + title: "Agent tool", + activities: [ + makeActivity({ + id: EventId.make("agent-call-updated"), + kind: "tool.updated", + tone: "tool", + summary: "Subagent task", + createdAt: at(1), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "inProgress", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + makeActivity({ + id: EventId.make("agent-started"), + kind: "task.started", + summary: "Locate code", + createdAt: at(2), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + }, + }), + makeActivity({ + id: EventId.make("agent-done"), + kind: "task.completed", + summary: "Locate code", + createdAt: at(3), + turnId, + payload: { + taskId: "a1", + agentKind: "agent", + taskType: "local_agent", + title: "Locate code", + toolUseId: "toolu_agent", + status: "completed", + }, + }), + makeActivity({ + id: EventId.make("agent-call-completed"), + kind: "tool.completed", + tone: "tool", + summary: "Subagent task", + createdAt: at(4), + turnId, + payload: { + itemType: "collab_agent_tool_call", + toolCallId: "toolu_agent", + status: "completed", + title: "Subagent task", + detail: "Locate code", + data: { toolName: "Agent" }, + }, + }), + ], + }), + ); + const rows = feed.flatMap((entry) => + entry.type === "activity-group" ? entry.activities.map((row) => row.id) : [], + ); + expect(rows).toEqual(["agent-started"]); + expect( + deriveThreadFeedPresentation(feed, null, new Set([turnId])).map((row) => row.type), + ).toEqual(["turn-fold", "agent-spawn"]); + }); + + it("presents a spawn batch as one card whose status line follows the newest member activity", () => { + const turnId = TurnId.make("turn-spawn-card"); + const latestTurn = { + turnId, + state: "running" as const, + requestedAt: "2026-04-01T00:00:00.000Z", + startedAt: "2026-04-01T00:00:00.000Z", + completedAt: null, + assistantMessageId: null, + }; + const agent = ( + id: string, + kind: "task.started" | "task.progress" | "task.completed", + taskId: string, + seconds: number, + extra: Record = {}, + ) => + makeActivity({ + id: EventId.make(id), + kind, + summary: `Agent ${taskId}`, + createdAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + turnId, + payload: { + taskId, + agentKind: "agent", + taskType: "local_agent", + title: `Agent ${taskId}`, + ...extra, + }, + }); + const presentFor = (activities: ReadonlyArray>) => + deriveThreadFeedPresentation( + buildThreadFeed( + makeThread({ + id: ThreadId.make("thread-spawn-card"), + projectId: ProjectId.make("project-1"), + title: "Spawn card", + latestTurn, + activities, + }), + ), + latestTurn, + new Set(), + new Set(), + latestTurn.startedAt, + ); + + // A working card is the live activity; no Thinking row sits under it. + const single = presentFor([agent("a-start", "task.started", "a", 1)]); + expect(single.map((row) => row.type)).toEqual(["agent-spawn"]); + expect(single[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { title: "Agent a", status: "Working", tone: "working" }, + }); + + // The server upserts the progress row with a new createdAt each tick; + // the card keeps its identity and only the status line changes. + const tick = (seconds: number, detail: string) => + presentFor([ + agent("a-start", "task.started", "a", 1), + agent("task-progress:a", "task.progress", "a", seconds, { detail }), + ]); + expect(tick(2, "Reading a.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { title: "Agent a", status: "Reading a.ts", tone: "working" }, + }); + expect(tick(3, "Reading b.ts")[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + createdAt: "2026-04-01T00:00:01.000Z", + summary: { status: "Reading b.ts" }, + }); + + const batch = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("task-progress:b", "task.progress", "b", 3, { detail: "Grepping" }), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + ]); + expect(batch[0]).toMatchObject({ + id: `agent-spawn:${turnId}`, + summary: { + title: "2 subagents", + status: "Grepping", + tone: "working", + members: [ + { title: "Agent a", status: "completed", tone: "completed" }, + { title: "Agent b", status: "working", tone: "working", detail: "Grepping" }, + ], + }, + }); + + const settled = presentFor([ + agent("a-start", "task.started", "a", 1), + agent("b-start", "task.started", "b", 2), + agent("a-done", "task.completed", "a", 4, { status: "completed" }), + agent("b-done", "task.completed", "b", 5, { status: "failed", error: "boom" }), + ]); + expect(settled[0]).toMatchObject({ + type: "agent-spawn", + summary: { title: "2 subagents", status: "1 failed", tone: "failed" }, + }); + expect(settled.map((row) => row.type)).toEqual(["agent-spawn", "thinking"]); + }); + it.each(["cancelled", "failed", "interrupted", "idle"] as const)( "replaces Antigravity batch progress with %s", (status) => { @@ -2988,6 +3256,55 @@ describe("quiet timeline: nested agents", () => { expect(rows[0]?.getFullDetail()).toBe("Reviewer 0 · completed\nReviewer 1 · completed"); }); + it("summarizes a spawn card from the newest member report and the batch outcome", () => { + type Member = NonNullable["agents"][number]; + const member = (title: string, status: Member["status"], detail: string, seconds: number) => + ({ + title, + status, + detail, + updatedAt: `2026-04-01T00:00:${String(seconds).padStart(2, "0")}.000Z`, + }) satisfies Member; + const direct = (agents: ReadonlyArray) => ({ + workflowId: null, + agentTaskIds: agents.map((_, index) => `a${index}`), + agents, + }); + + // The newest report wins regardless of member order. + expect( + agentSpawnSummary( + direct([ + member("Agent 0", "inProgress", "Reading b.ts", 5), + member("Agent 1", "inProgress", "Reading a.ts", 2), + ]), + "inProgress", + ), + ).toMatchObject({ title: "2 subagents", status: "Reading b.ts", tone: "working" }); + + // A declined request is a failed batch, not a completed one. + expect( + agentSpawnSummary(direct([member("Agent 0", "declined", "", 1)]), "declined"), + ).toMatchObject({ status: "failed", tone: "failed" }); + + // A coordinator that failed on its own reports the failure even when every + // member succeeded; before any member reports, the card has a neutral title. + const workflow = (agents: ReadonlyArray) => ({ + workflowId: "wf", + agentTaskIds: ["wf", ...agents.map((_, index) => `wf:wf:${index}`)], + agents: [member("review", "failed", "", 9), ...agents], + }); + expect( + agentSpawnSummary(workflow([member("Reviewer", "completed", "", 3)]), "failed"), + ).toMatchObject({ title: "Reviewer", status: "failed", tone: "failed" }); + expect( + agentSpawnSummary( + { workflowId: "wf", agentTaskIds: ["wf"], agents: [member("review", undefined, "", 1)] }, + "inProgress", + ), + ).toMatchObject({ title: "Subagents", status: "Working", tone: "working", members: [] }); + }); + it("treats a Codex child's idle turn end as a finished batch member", () => { const turnId = TurnId.make("turn-codex"); const child = ( @@ -3063,7 +3380,12 @@ describe("quiet timeline: nested agents", () => { expect(ids).toContain("nested-done"); expect(ids).not.toContain("shell-done"); expect(deriveThreadFeedPresentation(feed, null, new Set())).toMatchObject([ - { type: "activity-group", id: "nested-done" }, + { + type: "agent-spawn", + id: "agent-spawn:n-1", + activity: { id: "nested-done" }, + summary: { title: "Task completed", status: "completed", tone: "completed" }, + }, ]); }); }); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 1d6fb6c0e252..7286446fb2e8 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -122,6 +122,8 @@ export interface WorkLogEntry { readonly title: string; readonly status: WorkLogToolLifecycleStatus | undefined; readonly detail: string | undefined; + /** When this member last reported, so the card can show the newest activity. */ + readonly updatedAt: string; }>; }; toolData?: unknown; @@ -132,6 +134,8 @@ interface DerivedWorkLogEntry extends WorkLogEntry { collapseKey?: string; /** Grouping key for subagent lifecycle rows (one row per agent). */ taskId?: string; + /** The tool call that launched this agent, when the provider reports one. */ + agentSpawnToolCallId?: string; isWorkflowCoordinator?: boolean; /** Shell/monitor/plan tasks: ordinary work-log rows, never spawn batches. */ isBackgroundTask?: boolean; @@ -187,12 +191,47 @@ export type ThreadFeedEntry = readonly expanded: boolean; } | { + /** + * The turn's single live slot. Web keys its live tool row and its + * "Thinking" row identically so the slot updates in place; here the + * slot holds "Thinking" whenever no tool row is shimmering, so a tool + * failing does not insert a row under the group it lives in. + */ readonly type: "thinking"; readonly id: string; readonly createdAt: string; readonly turnId: TurnId | null; + } + | { + /** + * One batch of spawned subagents. Rendered as its own card because a + * single-line tool row has no room for what the agents are doing now, + * which on a phone is the one thing worth showing. + */ + readonly type: "agent-spawn"; + readonly id: string; + readonly createdAt: string; + readonly turnId: TurnId | null; + readonly activity: ThreadFeedActivity; + readonly expanded: boolean; + readonly summary: AgentSpawnSummary; }; +export interface AgentSpawnSummary { + /** "Locate UNO hand rendering code" for one agent, "3 subagents" for a batch. */ + readonly title: string; + /** Latest member activity while working, else the batch outcome. */ + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly members: ReadonlyArray<{ + readonly title: string; + readonly status: string; + readonly tone: "working" | "completed" | "failed" | "stopped"; + readonly detail: string | undefined; + readonly updatedAt: string; + }>; +} + export type ThreadFeedLatestTurn = Pick< OrchestrationLatestTurn, "turnId" | "state" | "startedAt" | "completedAt" @@ -420,6 +459,7 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean return false; } const isTaskRow = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.updated" || activity.kind === "task.completed"; @@ -441,6 +481,15 @@ function isAgentInternalActivity(activity: OrchestrationThreadActivity): boolean return payload.timelineBypass === true || ownedByAgent; } +/** Agent (non-background) task.started rows seed spawn batches. */ +function isAgentTaskStartedActivity(activity: OrchestrationThreadActivity): boolean { + const payload = + activity.payload && typeof activity.payload === "object" + ? (activity.payload as Record) + : null; + return typeof payload?.taskId === "string" && payload.agentKind === "agent"; +} + function deriveWorkLogEntries( activities: ReadonlyArray, ): DerivedWorkLogEntry[] { @@ -449,7 +498,11 @@ function deriveWorkLogEntries( 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; + // Like web: an agent's task.started row anchors its batch. It has a fixed + // id and timestamp, unlike progress ticks, whose stable per-task id is + // rewritten with a new createdAt on every update (and would otherwise + // make the batch row a "fresh" row again on each tick). + if (activity.kind === "task.started" && !isAgentTaskStartedActivity(activity)) continue; if (activity.kind === "task.updated" && !isTerminalTaskUpdate(activity)) continue; if (activity.kind === "tool.progress") continue; if (activity.kind === "context-window.updated") continue; @@ -496,6 +549,7 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const toolPresentation = extractToolActivityPresentation(payload); // Terminal task updates carry identity so they replace each child's progress row. const isTaskActivity = + activity.kind === "task.started" || activity.kind === "task.progress" || activity.kind === "task.completed" || activity.kind === "task.updated"; @@ -539,6 +593,10 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo if (payload.agentKind !== "agent") { entry.isBackgroundTask = true; } + const spawnToolCallId = asTrimmedString(payload.toolUseId); + if (spawnToolCallId) { + entry.agentSpawnToolCallId = spawnToolCallId; + } if ( payload.taskType === "local_workflow" || (typeof payload.workflowName === "string" && payload.workflowName.length > 0) @@ -608,8 +666,11 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo entry.requestKind = requestKind; } let toolLifecycleStatus = extractWorkLogToolLifecycleStatus(payload); - if (!toolLifecycleStatus && activity.kind === "tool.completed") { - toolLifecycleStatus = "completed"; + if ( + !toolLifecycleStatus && + (activity.kind === "tool.completed" || activity.kind === "task.completed") + ) { + toolLifecycleStatus = activity.tone === "error" ? "failed" : "completed"; } // A Codex child that finishes its turn reports "idle" (resumable, not // terminal). For the batch row that is a finished member. @@ -681,6 +742,7 @@ function agentSpawnMember( title: entry.toolTitle ?? previous?.title ?? entry.label, status: entry.toolLifecycleStatus ?? previous?.status, detail: entry.detail ?? previous?.detail, + updatedAt: entry.createdAt, }; } @@ -713,6 +775,7 @@ function agentSpawnLifecycleStatus( return "inProgress"; } if (statuses.includes("failed")) return "failed"; + if (statuses.includes("declined")) return "declined"; if (statuses.includes("stopped")) return "stopped"; return "completed"; } @@ -729,10 +792,26 @@ function collapseDerivedWorkLogEntries( const spawnRowIndex = new Map(); const spawnGroupByTaskId = new Map(); const toolLifecycleRowIndex = new Map(); + // Tool calls that launched an agent (Claude's Agent tool, ACP subagent + // calls). The batch card is the whole story of that call, so its own + // lifecycle row is dropped. + const spawnToolCallIds = new Set( + entries.flatMap((entry) => + entry.agentSpawnToolCallId !== undefined ? [entry.agentSpawnToolCallId] : [], + ), + ); for (const entry of entries) { + if ( + entry.toolCallId !== undefined && + entry.taskId === undefined && + spawnToolCallIds.has(entry.toolCallId) + ) { + continue; + } const isTaskRow = entry.taskId !== undefined && - (entry.sourceActivityKind === "task.progress" || + (entry.sourceActivityKind === "task.started" || + entry.sourceActivityKind === "task.progress" || entry.sourceActivityKind === "task.completed" || entry.sourceActivityKind === "task.updated"); if (isTaskRow && entry.taskId !== undefined) { @@ -1142,6 +1221,76 @@ function agentSpawnMembers(spawn: NonNullable) { return spawn.agents.filter((_, index) => spawn.agentTaskIds[index] !== spawn.workflowId); } +function agentSpawnTone(status: WorkLogToolLifecycleStatus | undefined): AgentSpawnSummary["tone"] { + switch (status) { + case undefined: + case "inProgress": + return "working"; + case "completed": + return "completed"; + case "failed": + case "declined": + return "failed"; + case "stopped": + return "stopped"; + } +} + +/** + * What the spawn card shows. While members work, the status line is the + * newest member activity (its progress detail), so the card reads like the + * live tool row does for a single call. Once every member settles, it is the + * batch outcome in web's CTA wording. + */ +export function agentSpawnSummary( + spawn: NonNullable, + batchStatus: WorkLogToolLifecycleStatus | undefined, +): AgentSpawnSummary { + const members = agentSpawnMembers(spawn).map((agent) => { + const tone = agentSpawnTone(agent.status); + return { + title: agent.title, + status: tone === "working" ? "working" : (agent.status ?? tone), + tone, + detail: agent.detail, + updatedAt: agent.updatedAt, + }; + }); + const tone = agentSpawnTone(batchStatus); + // A workflow's coordinator is not a member; before any member reports the + // batch has none. + const title = + members.length === 0 + ? "Subagents" + : members.length === 1 + ? members[0]!.title + : `${members.length} subagents`; + if (tone === "working") { + const working = members.filter((member) => member.tone === "working"); + const latest = working + .filter((member) => member.detail !== undefined) + .reduce<(typeof working)[number] | undefined>( + (newest, member) => + newest === undefined || member.updatedAt > newest.updatedAt ? member : newest, + undefined, + ); + const status = + latest?.detail ?? + (members.length > 1 ? `${working.length} of ${members.length} working` : "Working"); + return { title, status, tone, members }; + } + // The batch tone covers a coordinator that failed or stopped on its own. + const failed = members.filter((member) => member.tone === "failed").length; + const stopped = members.filter((member) => member.tone === "stopped").length; + const outcome = + tone === "failed" || failed > 0 + ? `${members.length > 1 && failed > 0 ? `${failed} ` : ""}failed` + : tone === "stopped" || stopped > 0 + ? `${members.length > 1 && stopped > 0 ? `${stopped} ` : ""}stopped` + : "completed"; + return { title, status: outcome, tone, members }; +} + function agentSpawnExpandedBody(spawn: NonNullable): string | null { const lines = agentSpawnMembers(spawn).map((agent) => { const status = @@ -1750,7 +1899,10 @@ export function deriveThreadFeedPresentation( ): ThreadFeedEntry[] { const sourceFeed = feed.filter( (entry) => - entry.type !== "turn-fold" && entry.type !== "work-toggle" && entry.type !== "thinking", + entry.type !== "turn-fold" && + entry.type !== "work-toggle" && + entry.type !== "thinking" && + entry.type !== "agent-spawn", ); const activeTailGroup = sourceFeed.findLast( (entry) => entry.type !== "message" || !isEmptyMessage(entry), @@ -1812,18 +1964,36 @@ export function deriveThreadFeedPresentation( } // A working turn always shows one live activity. When no tool row is // shimmering (no tools yet, or the latest failed), that row is "Thinking". + // The trailing group's live row and this row share LIVE_ACTIVITY_ROW_ID, so + // the handoff between them happens in place (one row, new content) instead + // of a row being inserted below the group every time a call fails. if ( activeWorkStartedAt !== null && - !result.some((row) => row.type === "work-toggle" && row.shimmer) + !result.some( + (row) => + (row.type === "work-toggle" && row.shimmer) || + // A working spawn card is the live activity: its status line shows + // what the agents are doing, so a Thinking row under it would lie. + (row.type === "agent-spawn" && + row.summary.tone === "working" && + row.turnId === unsettledTurnId), + ) ) { result.push(thinkingRow(activeWorkStartedAt, unsettledTurnId)); } return result; } +/** + * Shared by the trailing tool group's live row and the "Thinking" row so the + * list keeps one mounted row for the turn's live slot (mirrors web's + * LIVE_ACTIVITY_ROW_ID). Anything keyed by row id must not distinguish them. + */ +export const LIVE_ACTIVITY_ROW_ID = "live-activity-row"; + function thinkingRow(createdAt: string, turnId: TurnId | null) { if (cachedThinkingRow?.createdAt !== createdAt || cachedThinkingRow.turnId !== turnId) { - cachedThinkingRow = { type: "thinking", id: "thinking", createdAt, turnId }; + cachedThinkingRow = { type: "thinking", id: LIVE_ACTIVITY_ROW_ID, createdAt, turnId }; } return cachedThinkingRow; } @@ -1852,7 +2022,9 @@ function appendPresentedFeedEntry( cached.isWorking !== isWorking || cached.activeTail !== activeTail || cached.rows.some( - (row) => row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded, + (row) => + (row.type === "work-toggle" && expandedWorkGroupIds.has(row.groupId) !== row.expanded) || + (row.type === "agent-spawn" && expandedWorkGroupIds.has(row.id) !== row.expanded), ) ) { const rows: ThreadFeedEntry[] = []; @@ -1908,11 +2080,27 @@ function appendActivityGroupRows( groupableRun = []; }; for (const activity of activities) { - if (activity.workEntry.tone !== "error" && activity.workEntry.agentSpawn === undefined) { + const spawn = activity.workEntry.agentSpawn; + if (activity.workEntry.tone !== "error" && spawn === undefined) { groupableRun.push(activity); continue; } flushGroupableRun(false); + if (spawn !== undefined) { + // Keyed by the batch, not the anchor activity: the anchor can change + // as members arrive, and a changed key remounts the card. + const groupId = `agent-spawn:${spawn.workflowId ?? activity.turnId ?? spawn.agentTaskIds[0]}`; + result.push({ + type: "agent-spawn", + id: groupId, + createdAt: activity.createdAt, + turnId: activity.turnId, + activity, + expanded: expandedWorkGroupIds.has(groupId), + summary: agentSpawnSummary(spawn, activity.lifecycleStatus), + }); + continue; + } result.push({ type: "activity-group", id: activity.id, @@ -1953,7 +2141,9 @@ function appendToolGroupRows( const latestActivity = latestActiveActivity ?? activities.at(-1)!; // Like web, the trailing run keeps shining after its latest call succeeds; // only a failed, declined, or stopped call hands the live slot to "Thinking". - const shimmer = active || (activeTail && latestActivity.status === "success"); + // Only the trailing run can be the turn's live slot; an in-progress row in + // an earlier run (a call whose end was never reported) stays in place. + const shimmer = activeTail && (active || latestActivity.status === "success"); const singleActivity = activities.length === 1 ? latestActivity : null; const summary = live ? liveToolActivitySummary(latestActivity, live) @@ -1994,7 +2184,9 @@ function appendToolGroupRows( : undefined; result.push({ type: "work-toggle", - id: `${live ? "work-live" : "work-toggle"}:${groupId}`, + // The shimmering trailing row is the turn's live slot; it keeps that + // identity (and so its mounted view) until "Thinking" takes the slot. + id: shimmer ? LIVE_ACTIVITY_ROW_ID : `${live ? "work-live" : "work-toggle"}:${groupId}`, createdAt: sourceGroup.createdAt, turnId: sourceGroup.turnId, groupId, From a495385584276d0e568df23646a49ce8b40a4707 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:06:13 -0700 Subject: [PATCH 193/320] fix(mobile): save linked media from chat (#10271) --- .../src/features/threads/ThreadFeed.tsx | 21 ++++-- .../src/features/threads/fileChipMenu.test.ts | 45 +++++++++++- .../src/features/threads/fileChipMenu.ts | 36 +++++++++- .../src/features/threads/useFileChipShare.ts | 70 +++++++++++++++++++ 4 files changed, 163 insertions(+), 9 deletions(-) create mode 100644 apps/mobile/src/features/threads/useFileChipShare.ts diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 9b09ef6d903d..577fb3704ec7 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -178,6 +178,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; import { fileChipMenu, resolveFileChipTarget, type FileChipAction } from "./fileChipMenu"; +import { useFileChipShare } from "./useFileChipShare"; import { MarkdownImageAvailableWidthContext, ThreadMarkdownImage, @@ -1964,6 +1965,12 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { const { copiedRowId, expandedWorkGroups, expandedWorkRows, expandedTurnIds } = interactionState; const [expandedFile, setExpandedFile] = useState(null); const [expandedVideo, setExpandedVideo] = useState(null); + const fileShareSourceIdentifier = useId(); + const shareFileChip = useFileChipShare( + props.environmentId, + props.threadId, + fileShareSourceIdentifier, + ); useEffect(() => { setExpandedVideo(null); setExpandedFile(null); @@ -2116,10 +2123,13 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { case "open-file": onMarkdownLinkPress(href); return; + case "save": + shareFileChip(target); + return; } }, }), - [onMarkdownLinkPress, props.workspaceRoot], + [onMarkdownLinkPress, props.workspaceRoot, shareFileChip], ); const renderMarkdownImage = useCallback( (image) => { @@ -2710,7 +2720,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { } return ( - <> + ) : null} + setExpandedVideo(null)} /> + setExpandedFile(null)} /> - - setExpandedVideo(null)} /> - setExpandedFile(null)} /> - + ); }); diff --git a/apps/mobile/src/features/threads/fileChipMenu.test.ts b/apps/mobile/src/features/threads/fileChipMenu.test.ts index eb9bad3a4195..1627a72da5de 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.test.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.test.ts @@ -1,6 +1,7 @@ +import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { fileChipMenu, resolveFileChipTarget } from "./fileChipMenu"; +import { fileChipMenu, fileChipShareSource, resolveFileChipTarget } from "./fileChipMenu"; describe("resolveFileChipTarget", () => { it("resolves a workspace-relative link to both paths", () => { @@ -42,3 +43,45 @@ describe("fileChipMenu", () => { ]); }); }); + +describe("file chip downloads", () => { + const threadId = ThreadId.make("thread-1"); + + it.each([ + [ + "/tmp/maria-counter/maria-counter-final.mp4", + "/tmp/maria-counter/maria-counter-final.mp4", + "video/mp4", + ], + ["/tmp/take%2520%23one.mp4:12", "/tmp/take%20#one.mp4", "video/mp4"], + ["/tmp/report.pdf", "/tmp/report.pdf", "application/pdf"], + ["screens/image.PNG", "/repo/screens/image.PNG", "image/png"], + ])("offers a host download for %s", (href, path, mimeType) => { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipMenu(target).actions).toContainEqual({ + id: "save", + title: "Save or share", + }); + expect(fileChipShareSource(target, threadId)).toEqual({ + name: path.split("/").at(-1), + mimeType, + resource: { _tag: "media-file", threadId, path }, + }); + }); + + it("retains the thread context for a relative file without a known workspace root", () => { + expect( + fileChipShareSource(resolveFileChipTarget("clips/demo.mp4", null)!, threadId), + ).toMatchObject({ + resource: { _tag: "media-file", threadId, path: "clips/demo.mp4" }, + }); + }); + + it("does not offer downloads the host asset endpoint cannot serve", () => { + for (const href of ["src/app.ts", "/tmp/archive.zip", "/tmp/clip.mp4.txt"]) { + const target = resolveFileChipTarget(href, "/repo")!; + expect(fileChipShareSource(target, threadId)).toBeNull(); + expect(fileChipMenu(target).actions.some(({ id }) => id === "save")).toBe(false); + } + }); +}); diff --git a/apps/mobile/src/features/threads/fileChipMenu.ts b/apps/mobile/src/features/threads/fileChipMenu.ts index 3630a62b3551..9f82e089444d 100644 --- a/apps/mobile/src/features/threads/fileChipMenu.ts +++ b/apps/mobile/src/features/threads/fileChipMenu.ts @@ -1,5 +1,8 @@ +import { fileBasename } from "@t3tools/client-runtime/markdown-links"; +import type { ThreadId } from "@t3tools/contracts"; import { resolveMarkdownLinkPresentation } from "@t3tools/mobile-markdown-text/links"; import type { MarkdownFileContextMenu } from "@t3tools/mobile-markdown-text/types"; +import { hostPreviewMimeTypeFromExtension } from "@t3tools/shared/filePreview"; import { isAbsolutePath, @@ -7,7 +10,7 @@ import { resolveWorkspaceRelativeFilePath, } from "../files/filePath"; -export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file"; +export type FileChipAction = "copy-full-path" | "copy-relative-path" | "open-file" | "save"; export interface FileChipTarget { /** The host path, when the link is absolute or the workspace root is known. */ @@ -36,7 +39,28 @@ export function resolveFileChipTarget( }; } -/** The same actions the web file chip offers on right-click. Opening is what a tap does. */ +function fileChipMetadata(target: FileChipTarget) { + const path = target.fullPath ?? target.relativePath; + if (!path) return null; + const name = fileBasename(path); + const dot = name.lastIndexOf("."); + const mimeType = dot < 0 ? null : hostPreviewMimeTypeFromExtension(name.slice(dot)); + return mimeType ? { path, name, mimeType } : null; +} + +/** Use literal resolved paths so encoded filename characters are not decoded twice. */ +export function fileChipShareSource(target: FileChipTarget, threadId: ThreadId) { + const metadata = fileChipMetadata(target); + return metadata + ? { + name: metadata.name, + mimeType: metadata.mimeType, + resource: { _tag: "media-file" as const, threadId, path: metadata.path }, + } + : null; +} + +/** Saving is available for the media and documents the host asset endpoint can serve. */ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { return { title: target.fullPath ?? target.relativePath ?? "", @@ -44,6 +68,14 @@ export function fileChipMenu(target: FileChipTarget): MarkdownFileContextMenu { ...(target.fullPath ? [{ id: "copy-full-path", title: "Copy full path" }] : []), ...(target.relativePath ? [{ id: "copy-relative-path", title: "Copy relative path" }] : []), { id: "open-file", title: "Open in file viewer" }, + ...(fileChipMetadata(target) + ? [ + { + id: "save", + title: "Save or share", + }, + ] + : []), ], }; } diff --git a/apps/mobile/src/features/threads/useFileChipShare.ts b/apps/mobile/src/features/threads/useFileChipShare.ts new file mode 100644 index 000000000000..587aa8ad9572 --- /dev/null +++ b/apps/mobile/src/features/threads/useFileChipShare.ts @@ -0,0 +1,70 @@ +import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; +import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { useCallback, useEffect, useLayoutEffect, useRef } from "react"; +import { Alert } from "react-native"; + +import { downloadAndShareAttachment } from "../../lib/attachmentDownload"; +import { assetEnvironment } from "../../state/assets"; +import { usePreparedConnection } from "../../state/session"; +import { useAtomQueryRunner } from "../../state/use-atom-query-runner"; +import { fileChipShareSource, type FileChipTarget } from "./fileChipMenu"; + +/** Fetches host files through the selected environment before opening the native save/share sheet. */ +export function useFileChipShare( + environmentId: EnvironmentId, + threadId: ThreadId, + sourceIdentifier: string, +) { + const connection = usePreparedConnection(environmentId); + const httpBaseUrl = Option.isSome(connection) ? connection.value.httpBaseUrl : null; + const createUrl = useAtomQueryRunner(assetEnvironment.createUrl, { + refresh: true, + reportFailure: false, + }); + const connectionRef = useRef(httpBaseUrl); + useLayoutEffect(() => { + connectionRef.current = httpBaseUrl; + }, [httpBaseUrl]); + const requestRef = useRef(null); + useEffect(() => () => requestRef.current?.abort(), []); + + const share = useCallback( + (target: FileChipTarget) => { + const source = fileChipShareSource(target, threadId); + if (!source || requestRef.current) return; + const request = new AbortController(); + requestRef.current = request; + const httpBaseUrl = connectionRef.current; + void (async () => { + if (httpBaseUrl === null) throw new Error("Reconnect to the environment and try again."); + const result = await createUrl({ environmentId, input: { resource: source.resource } }); + if (request.signal.aborted) return; + const url = + result._tag === "Success" ? resolveAssetUrl(httpBaseUrl, result.value.relativeUrl) : null; + if (url === null) throw new Error("The file could not be loaded. Reconnect and try again."); + await downloadAndShareAttachment({ + url, + attachment: source, + signal: request.signal, + sourceIdentifier, + }); + })() + .catch((error: unknown) => { + if (!request.signal.aborted) { + Alert.alert( + "Could not share file", + error instanceof Error ? error.message : "Try again.", + ); + } + }) + .finally(() => { + if (requestRef.current === request) { + requestRef.current = null; + } + }); + }, + [createUrl, environmentId, sourceIdentifier, threadId], + ); + return share; +} From 272d6d747ef214f50dbd9d0051120de12ffa3591 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 5 Sep 2026 23:21:44 -0700 Subject: [PATCH 194/320] feat(markdown): show the GitHub mark for github.com links (#10324) Co-authored-by: Claude Code --- .../assets/link-icons/github.png | Bin 0 -> 1727 bytes .../t3-markdown-text/ios/T3MarkdownText.mm | 29 +++++++++------ .../ios/T3MarkdownTextShadowNode.h | 2 ++ .../ios/T3MarkdownTextShadowNode.mm | 11 ++++++ .../modules/t3-markdown-text/package.json | 1 + .../src/NativeMarkdownSelectableText.ios.tsx | 34 ++++++++++++++---- .../t3-markdown-text/src/markdownLinkIcons.ts | 12 +++++++ .../t3-markdown-text/src/markdownLinks.ts | 12 +++++++ .../src/features/threads/ThreadFeed.tsx | 17 ++++++--- apps/mobile/src/lib/markdownLinks.test.ts | 16 ++++++++- apps/web/src/components/ChatMarkdown.test.tsx | 13 +++++-- apps/web/src/components/ChatMarkdown.tsx | 15 ++++++-- 12 files changed, 135 insertions(+), 27 deletions(-) create mode 100644 apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png create mode 100644 apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts diff --git a/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png b/apps/mobile/modules/t3-markdown-text/assets/link-icons/github.png new file mode 100644 index 0000000000000000000000000000000000000000..87eab9f5218ffce249bd6019b59b55d9106f44f5 GIT binary patch literal 1727 zcmV;w20;0VP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91K%fHv1ONa40RR91KmY&$07g+lumAuCDM>^@RCodHnrn<+Lm0=qq*a%# zmM!YKl&xgT7up7~5j8!DnD2*a6y(&cfqC@Gn6yIO&!R z*{Tf9!jpcm3*-e*uYTE*P1$Z4bzu2spm(%hfm!v)wtSE;O{KY58v<5`8znDaz;uY8hk4I@pnb)cw&%^D{k>;6hiMd`C`5PXtk`6{35LQ=E=B+A>Rl+UG; zpsCbOPu_nx^1amS&<^jsAGF_A424K5s;{hF+vcx>zNs;vx~q{}2yOzJZlj>lN!o{v zm%%MyQ4N1kX4zFN(fb6wGebEFWo#?(>2MgUK_hmDzo2&j91VPW6-HJ9)Tfvxtp}4B z2x)8iB*-JlEK3nO4F0`f6_{3&`JHkT_yT+jc7bHZOs75*%mLk?7buw`IsX(`1C-bh z$<8C84v)8#wsfV^EP7&}-ilN$`#TiKZQx9Bze;I!0~+wD?&_S+5FNeVG*N*+ddM^%J?_szi?;FBb5B!X4va0R=ZyC;{i-i#ABf z!-**s^EiD%EoM+6^Yr-0>QwGF#-Q8gG13BJxw_W2^MwC~R_d=dQ`Da`JS9NC*TvFi1pkZ`ZjqdRQ;ek^CE&~)$hJz<;$xQ*&}r~gUx2EU z_5-ohX-&Yvv6@>bXYp|?0hUjoWwx{`2fB=jr4G#i-O9vL6*oY>?ZKSWs3RZo6*)yQY`IJ0!Ct`k(2xagM70^wXHwdV01(QZ8Hpz=!Hi4W{O*V+ol9; zF?v{~t1#bn>pkV?Qg9W>J30CN7x|Ve(C)?u?NWVTv)Wh6@8gVvr0^Y}*;6H2k26`p zhWfOqkCCpoLIPR5m9?=|C@w4D|Aa%6epSf|Hq@u-c4-Bo*N-nh74Uz;-NCs96euaW zgs?6=45=HPho+q%^nmhs3wj%Cvt#-;6v*Zp|>NY3NR8 zEq-|py^?1zqyO;;{dxA?e#MqcntpEscc5Ee;b=IBy4DD{->)xoW+IPf8S_tRL$ zPS)5m3D7GT{obcJF^!H5Ae3pX2ls(fK!^^3>29JzGI_kc2!w3s@!nvZ7U{Z>lJFeJ zGOBOv2VkgHj!^1{N+qET>A;m`H`MnMW8`yPNmFSMgbZf!4lU`0evQh~@Av7QclRnK z%}*YFEiB6pEp^{LS#^-J53V0%sI5=kjX@0!V9rJ7Mi6qu=jo7DhP0LTb^2|cQhUl6 zDKb{C#B!i_-*?DuYco@Zw2rv?-1?97-3}T$<@21MfxrMryOt_@QQ`)i)b*d^Yrzqq zGNgC?PB07-N57(8_A~gAqi-wqHK6RQLPcX~5$cx!9cjx_7Ydew^UEswe|#VV{{pUG Vd4ZqLelGw3002ovPDHLkV1lP;E0X{K literal 0 HcmV?d00001 diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm index 25f1e94c110f..d42be2e174db 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownText.mm @@ -60,14 +60,16 @@ static void T3MarkdownTextApplyAttachments( NSString *imageUri = [NSString stringWithUTF8String:attachmentRange.imageUri.c_str()]; NSTextAttachment *attachment = [[NSTextAttachment alloc] init]; UIImage *image = images[imageUri]; - if ([imageUri hasPrefix:@"sf:"]) { - NSString *symbolName = [imageUri substringFromIndex:3]; - UIColor *foregroundColor = - [attributedString attribute:NSForegroundColorAttributeName - atIndex:attachmentRange.location - effectiveRange:nil] ?: UIColor.labelColor; - image = [[UIImage systemImageNamed:symbolName] imageWithTintColor:foregroundColor - renderingMode:UIImageRenderingModeAlwaysOriginal]; + const BOOL isSymbol = [imageUri hasPrefix:@"sf:"]; + if (isSymbol) { + image = [UIImage systemImageNamed:[imageUri substringFromIndex:3]]; + } + UIColor *foregroundColor = [attributedString attribute:NSForegroundColorAttributeName + atIndex:attachmentRange.location + effectiveRange:nil]; + if (image != nil && (isSymbol || attachmentRange.tintWithForeground)) { + image = [image imageWithTintColor:foregroundColor ?: UIColor.labelColor + renderingMode:UIImageRenderingModeAlwaysOriginal]; } attachment.image = image ?: [[UIImage alloc] init]; const CGFloat attachmentSize = T3MarkdownTextAttachmentSize(attachmentRange); @@ -79,8 +81,15 @@ static void T3MarkdownTextApplyAttachments( const NSRange range = NSMakeRange( attachmentRange.location, MIN(attachmentRange.length, attributedString.length - attachmentRange.location)); - NSAttributedString *attachmentString = - [NSAttributedString attributedStringWithAttachment:attachment]; + NSMutableAttributedString *attachmentString = + [[NSAttributedString attributedStringWithAttachment:attachment] mutableCopy]; + // Keep the run color on the attachment so a later re-apply (after the image + // loads asynchronously) still tints with the link color, not labelColor. + if (foregroundColor != nil) { + [attachmentString addAttribute:NSForegroundColorAttributeName + value:foregroundColor + range:NSMakeRange(0, attachmentString.length)]; + } [attributedString replaceCharactersInRange:range withAttributedString:attachmentString]; } } diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h index 99417490a63b..e6ce2b3226f0 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.h @@ -26,6 +26,8 @@ struct T3MarkdownTextAttachmentRange { size_t location; size_t length; std::string imageUri; + /// Recolor the loaded image with the run's foreground color, like `sf:` symbols. + bool tintWithForeground; }; inline Float T3MarkdownTextAttachmentSize(const T3MarkdownTextAttachmentRange &) { diff --git a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm index b9abe452fb94..60bbcf2e4f84 100644 --- a/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm +++ b/apps/mobile/modules/t3-markdown-text/ios/T3MarkdownTextShadowNode.mm @@ -11,6 +11,7 @@ static constexpr Float ParagraphStyleEncodingOffset = 1000; static constexpr auto FileAttachmentNativeIdPrefix = "t3-file:"; static constexpr auto SkillAttachmentNativeIdPrefix = "t3-skill:"; +static constexpr auto LinkAttachmentNativeIdPrefix = "t3-link:"; static void applyParagraphStyles( NSMutableAttributedString *attributedString, @@ -192,6 +193,7 @@ static void applyAttachments( utf16Offset, 1, props.nativeId.substr(std::char_traits::length(FileAttachmentNativeIdPrefix)), + false, }); } else if ( props.nativeId.rfind(SkillAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { @@ -200,6 +202,15 @@ static void applyAttachments( 1, props.nativeId.substr( std::char_traits::length(SkillAttachmentNativeIdPrefix)), + false, + }); + } else if ( + props.nativeId.rfind(LinkAttachmentNativeIdPrefix, 0) == 0 && fragmentLength > 0) { + attachmentRanges.push_back(T3MarkdownTextAttachmentRange{ + utf16Offset, + 1, + props.nativeId.substr(std::char_traits::length(LinkAttachmentNativeIdPrefix)), + true, }); } utf16Offset += fragmentLength; diff --git a/apps/mobile/modules/t3-markdown-text/package.json b/apps/mobile/modules/t3-markdown-text/package.json index 1e52d7695ec6..8922c8868c44 100644 --- a/apps/mobile/modules/t3-markdown-text/package.json +++ b/apps/mobile/modules/t3-markdown-text/package.json @@ -21,6 +21,7 @@ "exports": { ".": "./index.ts", "./file-icons": "./src/markdownFileIcons.ts", + "./link-icons": "./src/markdownLinkIcons.ts", "./links": "./src/markdownLinks.ts", "./markdown": "./src/nativeMarkdownText.ts", "./primitive": "./src/MarkdownTextPrimitive.tsx", diff --git a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx index 590a2fb1bd1b..a5c6cf540f1c 100644 --- a/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx +++ b/apps/mobile/modules/t3-markdown-text/src/NativeMarkdownSelectableText.ios.tsx @@ -12,6 +12,8 @@ import { import { MarkdownTextPrimitive } from "./MarkdownTextPrimitive"; import { markdownFileIconSource } from "./markdownFileIcons"; +import { markdownLinkIconSource } from "./markdownLinkIcons"; +import { resolveMarkdownLinkIcon } from "./markdownLinks"; import type { NativeMarkdownTextRun } from "./nativeMarkdownText"; import type { MarkdownFileContextMenu, @@ -177,10 +179,14 @@ export function NativeMarkdownSelectableText(props: { }) { const colorScheme = useColorScheme(); const menu = useContext(MarkdownFileContextMenuContext); - const containsInlineFileIcon = props.runs.some((run) => run.fileIcon != null); + const containsInlineIcon = props.runs.some( + (run) => + run.fileIcon != null || + (run.externalHost != null && resolveMarkdownLinkIcon(run.externalHost) !== null), + ); const attachAndroidText = useCallback( (textView: RNText | null) => { - if (Platform.OS !== "android" || !containsInlineFileIcon || textView === null) { + if (Platform.OS !== "android" || !containsInlineIcon || textView === null) { return; } const reactTag = findNodeHandle(textView); @@ -188,7 +194,7 @@ export function NativeMarkdownSelectableText(props: { installMarkdownCopySanitizer(reactTag); } }, - [containsInlineFileIcon], + [containsInlineIcon], ); const occurrences = new Map(); const prefixedExternalLinks = new Set(); @@ -198,6 +204,7 @@ export function NativeMarkdownSelectableText(props: { occurrences.set(signature, occurrence + 1); let text = run.text; + let linkIcon = null; if (run.fileIcon && Platform.OS === "ios") { text = `${INLINE_ATTACHMENT_PREFIX}${text}`; } else if (run.skillName && run.skillLabel) { @@ -207,10 +214,15 @@ export function NativeMarkdownSelectableText(props: { : `$${run.skillName}`; } else if (run.externalHost && run.href && !prefixedExternalLinks.has(run.href)) { prefixedExternalLinks.add(run.href); - text = `${EXTERNAL_LINK_PREFIX}${text}`; + linkIcon = resolveMarkdownLinkIcon(run.externalHost); + if (linkIcon === null) { + text = `${EXTERNAL_LINK_PREFIX}${text}`; + } else if (Platform.OS === "ios") { + text = `${INLINE_ATTACHMENT_PREFIX}${text}`; + } } - return { key: `${signature}:${occurrence}`, run, text }; + return { key: `${signature}:${occurrence}`, run, text, linkIcon }; }); // T3MarkdownText only rebuilds its attributed string during native layout. A // color-only child update can otherwise leave the previous appearance cached. @@ -248,7 +260,7 @@ export function NativeMarkdownSelectableText(props: { lineHeight: props.textStyle.lineHeight, }} > - {keyedRuns.map(({ key, run, text }) => { + {keyedRuns.map(({ key, run, text, linkIcon }) => { const href = run.href; const contextMenu = run.fileIcon && href ? menu?.fileContextMenu(href) : undefined; return ( @@ -260,7 +272,9 @@ export function NativeMarkdownSelectableText(props: { ? `t3-file:${Image.resolveAssetSource(markdownFileIconSource(run.fileIcon)).uri}` : run.skillName ? "t3-skill:sf:cube" - : undefined + : linkIcon + ? `t3-link:${Image.resolveAssetSource(markdownLinkIconSource(linkIcon)).uri}` + : undefined : undefined } contextMenuConfig={contextMenu ? JSON.stringify(contextMenu) : undefined} @@ -284,6 +298,12 @@ export function NativeMarkdownSelectableText(props: { > {Platform.OS === "android" && run.fileIcon ? ( + ) : Platform.OS === "android" && linkIcon ? ( + ) : null} {text} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts new file mode 100644 index 000000000000..568a51005798 --- /dev/null +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinkIcons.ts @@ -0,0 +1,12 @@ +import type { ImageSourcePropType } from "react-native"; + +import type { MarkdownLinkIcon } from "./markdownLinks"; + +// Black-on-transparent marks; callers tint them with the link color. +const MARKDOWN_LINK_ICON_SOURCES = { + github: require("../assets/link-icons/github.png"), +} as const satisfies Readonly>; + +export function markdownLinkIconSource(icon: MarkdownLinkIcon): ImageSourcePropType { + return MARKDOWN_LINK_ICON_SOURCES[icon]; +} diff --git a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts index 176585344167..19f71f631663 100644 --- a/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts +++ b/apps/mobile/modules/t3-markdown-text/src/markdownLinks.ts @@ -33,6 +33,18 @@ export type MarkdownLinkPresentation = export type MarkdownFileIcon = keyof typeof MARKDOWN_FILE_ICON_SOURCES; +export type MarkdownLinkIcon = "github"; + +/** + * Sites whose brand mark replaces the generic external-link glyph. The marks + * are monochrome and tinted with the link color, so they follow the theme. + */ +export function resolveMarkdownLinkIcon(host: string): MarkdownLinkIcon | null { + const hostname = host.toLowerCase(); + if (hostname === "github.com" || hostname.endsWith(".github.com")) return "github"; + return null; +} + const FILE_ICON_BY_NAME: Readonly> = { ".babelrc": "babel", ".babelrc.json": "babel", diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 577fb3704ec7..f1456aabd258 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -134,9 +134,11 @@ import { import { useAppearancePreferences } from "../settings/appearance/AppearancePreferencesProvider"; import { useAppearanceCodeSurface } from "../settings/appearance/useAppearanceCodeSurface"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; +import { markdownLinkIconSource } from "@t3tools/mobile-markdown-text/link-icons"; import { normalizeNativeMarkdownUrl, resolveMarkdownInlineCodePresentation, + resolveMarkdownLinkIcon, resolveMarkdownLinkPresentation, } from "@t3tools/mobile-markdown-text/links"; import { @@ -606,7 +608,8 @@ const MarkdownExternalLink = memo(function MarkdownExternalLink(props: { readonly onPress: (href: string) => void; }) { const [failedHost, setFailedHost] = useState(null); - const faviconUrl = faviconUrlForOrigin(`https://${props.host}`); + const linkIcon = resolveMarkdownLinkIcon(props.host); + const faviconUrl = linkIcon ? null : faviconUrlForOrigin(`https://${props.host}`); return ( - {faviconUrl !== null && - failedHost !== props.host && - !failedMarkdownFaviconHosts.has(props.host) ? ( + {linkIcon ? ( + + ) : faviconUrl !== null && + failedHost !== props.host && + !failedMarkdownFaviconHosts.has(props.host) ? ( { + it("gives GitHub hosts the brand mark and everything else the generic glyph", () => { + expect(resolveMarkdownLinkIcon("github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("GitHub.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("gist.github.com")).toBe("github"); + expect(resolveMarkdownLinkIcon("github.community")).toBeNull(); + expect(resolveMarkdownLinkIcon("notgithub.com")).toBeNull(); + expect(resolveMarkdownLinkIcon("example.com")).toBeNull(); + }); +}); describe("resolveMarkdownLinkPresentation", () => { it("treats protocol-relative media as an external URL, not a filesystem path", () => { diff --git a/apps/web/src/components/ChatMarkdown.test.tsx b/apps/web/src/components/ChatMarkdown.test.tsx index 3243bf3c2788..2c4a1fa7af6e 100644 --- a/apps/web/src/components/ChatMarkdown.test.tsx +++ b/apps/web/src/components/ChatMarkdown.test.tsx @@ -5,6 +5,7 @@ import { create, type ReactTestRenderer } from "react-test-renderer"; import { describe, expect, it, vi } from "vite-plus/test"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { GitHubIcon } from "./Icons"; import { Button } from "./ui/button"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; @@ -78,10 +79,10 @@ describe("ChatMarkdown favicon privacy", () => { const markdown = (url: string) => ; try { await act(async () => { - renderer = create(markdown("https://github.com")); + renderer = create(markdown("https://example.com")); }); expect(renderer!.root.findAllByType("img").map((image) => image.props.src)).toEqual([ - "https://www.google.com/s2/favicons?domain=github.com&sz=32", + "https://www.google.com/s2/favicons?domain=example.com&sz=32", ]); for (const url of ["http://192.168.1.10:8080", "http://localhost:3000", "http://home.arpa"]) { await act(async () => { @@ -90,9 +91,15 @@ describe("ChatMarkdown favicon privacy", () => { expect(renderer!.root.findAllByType("img")).toHaveLength(0); } await act(async () => { - renderer!.update(markdown("https://github.com")); + renderer!.update(markdown("https://example.com")); }); expect(renderer!.root.findAllByType("img")).toHaveLength(1); + // GitHub links draw the brand mark in currentColor instead of fetching a favicon. + await act(async () => { + renderer!.update(markdown("https://github.com/pingdotgg/t3code/pull/1")); + }); + expect(renderer!.root.findAllByType("img")).toHaveLength(0); + expect(renderer!.root.findAllByType(GitHubIcon)).toHaveLength(1); } finally { await act(async () => { renderer?.unmount(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 89d886ec7d1b..d72001eb9ddd 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -119,6 +119,7 @@ import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; +import { GitHubIcon } from "./Icons"; import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings, useClientSettings } from "../hooks/useSettings"; @@ -1189,15 +1190,25 @@ const MARKDOWN_LINK_FAVICON_CLASS_NAME = "block size-full shrink-0 select-none"; /** Hosts whose favicon request already failed this session — skip straight to the globe. */ const failedFaviconHosts = new Set(); +/** Sites whose brand mark (drawn in `currentColor`) replaces the fetched favicon so it follows the theme. */ +function brandLinkIcon(host: string): typeof GitHubIcon | null { + const hostname = host.toLowerCase(); + if (hostname === "github.com" || hostname.endsWith(".github.com")) return GitHubIcon; + return null; +} + const MarkdownLinkFavicon = memo(function MarkdownLinkFavicon({ host }: { host: string }) { const [failedHost, setFailedHost] = useState(null); - const faviconUrl = faviconUrlForOrigin(`https://${host}`); + const BrandIcon = brandLinkIcon(host); + const faviconUrl = BrandIcon ? null : faviconUrlForOrigin(`https://${host}`); return ( - {faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( + {BrandIcon ? ( + + ) : faviconUrl === null || failedHost === host || failedFaviconHosts.has(host) ? ( ) : ( Date: Sat, 5 Sep 2026 23:28:05 -0700 Subject: [PATCH 195/320] fix(marketing): show a real preview card when t3.codes is shared (#10305) Co-authored-by: Claude Fable 5.1 --- apps/marketing/astro.config.mjs | 1 + apps/marketing/src/layouts/Layout.astro | 33 ++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/marketing/astro.config.mjs b/apps/marketing/astro.config.mjs index 6f37ae922dad..5ba3da4fba10 100644 --- a/apps/marketing/astro.config.mjs +++ b/apps/marketing/astro.config.mjs @@ -1,6 +1,7 @@ import { defineConfig } from "astro/config"; export default defineConfig({ + site: "https://t3.codes", server: { port: Number(process.env.PORT ?? 4173), }, diff --git a/apps/marketing/src/layouts/Layout.astro b/apps/marketing/src/layouts/Layout.astro index ca5ea15f61de..4c95bc86560e 100644 --- a/apps/marketing/src/layouts/Layout.astro +++ b/apps/marketing/src/layouts/Layout.astro @@ -1,6 +1,7 @@ --- -import { Image } from "astro:assets"; +import { getImage, Image } from "astro:assets"; import appIcon from "../assets/icon.webp"; +import desktopScreenshot from "../assets/app-desktop.webp"; import dmSansLatinUrl from "../assets/fonts/dm-sans-latin.woff2?url"; import "../styles/fonts.css"; import { @@ -21,6 +22,21 @@ const { description = "T3 Code. The open-source control plane for coding agents.", pageClass, } = Astro.props; + +// Social preview card. Link unfurlers (Instagram, iMessage, X, Slack) want a +// 1200x630 jpg or png at an absolute URL. Built from the hero screenshot so it +// stays in sync with the homepage. +const socialImage = await getImage({ + src: desktopScreenshot, + width: 1200, + height: 630, + fit: "cover", + position: "top", + format: "jpg", + quality: 90, +}); +const socialImageUrl = new URL(socialImage.src, Astro.site); +const canonicalUrl = new URL(Astro.url.pathname, Astro.site); --- @@ -28,6 +44,21 @@ const { + + + + + + + + + + + + + + + Date: Sun, 6 Sep 2026 00:02:37 -0700 Subject: [PATCH 196/320] feat(usage): pool subscription limits per provider across accounts and environments (#10300) --- .../src/usage/cliproxyUsageLimits.test.ts | 16 + apps/server/src/usage/cliproxyUsageLimits.ts | 4 +- apps/web/src/components/usage/UsageLimits.tsx | 327 +++------- .../components/usage/UsageLimitsPooled.tsx | 570 ++++++++++++++++++ apps/web/src/components/usage/UsagePage.tsx | 39 +- .../components/usage/usageLimitsFixture.ts | 413 +++++++++++++ docs/user/usage.md | 15 +- packages/shared/src/usageLimits.test.ts | 378 ++++++++++++ packages/shared/src/usageLimits.ts | 307 +++++++++- 9 files changed, 1823 insertions(+), 246 deletions(-) create mode 100644 apps/web/src/components/usage/UsageLimitsPooled.tsx create mode 100644 apps/web/src/components/usage/usageLimitsFixture.ts diff --git a/apps/server/src/usage/cliproxyUsageLimits.test.ts b/apps/server/src/usage/cliproxyUsageLimits.test.ts index 19767f3a9270..5e8d1b1fff7a 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.test.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.test.ts @@ -102,6 +102,22 @@ describe("cliproxyStatusToAccounts", () => { }, ]); }); + + it("names a Codex five-hour window `primary`, as the Codex driver does", () => { + const accounts = cliproxyStatusToAccounts( + { + accounts: { + "codex-abc-someone@example.com-pro.json": { + provider: "codex", + plan: "pro", + five_hour: { hard_limited: false, known: true, used_percent: 40 }, + }, + }, + }, + checkedAt, + ); + expect(accounts[0]?.usageLimits.windows.map((window) => window.id)).toEqual(["primary"]); + }); }); describe("accountEmailFromAuthFile", () => { diff --git a/apps/server/src/usage/cliproxyUsageLimits.ts b/apps/server/src/usage/cliproxyUsageLimits.ts index cd2b1e277da6..47ed200c3f22 100644 --- a/apps/server/src/usage/cliproxyUsageLimits.ts +++ b/apps/server/src/usage/cliproxyUsageLimits.ts @@ -124,7 +124,9 @@ export function cliproxyAccountToUsageLimits( if (!window || window.known === false) continue; const resetsAt = isoFromHub(window.reset_at); windows.push({ - id: spec.id, + // Codex names its five-hour window by position, so a hub row and a + // native row for the same account pool together. + id: spec.key === "five_hour" && account.provider === "codex" ? "primary" : spec.id, kind: spec.kind, label: spec.label, windowDurationMins: spec.windowDurationMins, diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 1a72af35c909..0f15631acb99 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -5,21 +5,16 @@ import { ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, - UsageLimitSourceAccount, - UsageLimitSourceSnapshot, UsageProviderKind, } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { - collectLimitSources, - collectLimitsGroups, + collectLimitAccounts, elapsedShare, formatDuration, formatResetsIn, - limitsNotice, type LimitPace, paceOf, - providerLimitsLabel, remainingPercent, } from "@t3tools/shared/usageLimits"; import { GaugeIcon, TrendingDownIcon, TrendingUpIcon } from "lucide-react"; @@ -30,9 +25,6 @@ import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatUpcomingTimestamp } from "../../timestampFormat"; -import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; -import { getDriverOption } from "../settings/providerDriverMeta"; -import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; import { AlertDialog, AlertDialogClose, @@ -44,6 +36,8 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import type { makeLimitsFixture } from "./usageLimitsFixture"; +import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; const PACE: Record = { @@ -53,14 +47,14 @@ const PACE: Record @@ -202,94 +196,6 @@ export function LimitWindows({ ); } -/** - * Heading shared by local providers and source accounts: icon, driver, instance, plan, - * and the signed-in email blurred until clicked, as provider settings do. - */ -function AccountHeading({ - driver, - label, - instanceLabel, - plan, - email, - accentColor, -}: { - readonly driver: ServerProvider["driver"]; - readonly label: string; - readonly instanceLabel: string; - readonly plan: string | undefined; - readonly email: string | undefined; - readonly accentColor?: string | undefined; -}) { - return ( -

- - {label} - {instanceLabel !== label ? ( - - · {instanceLabel} - - ) : null} - {plan ? · {plan} : null} - {email ? ( - - ) : null} -

- ); -} - -function ProviderLimits({ - provider, - environmentId, - now, -}: { - readonly provider: ServerProvider; - readonly environmentId: EnvironmentId; - readonly now: number; -}) { - const limits = provider.usageLimits; - if (!limits) return null; - const notice = limitsNotice(limits); - return ( -
- getDriverOption(driver)?.label)} - plan={provider.auth.label} - email={provider.auth.email} - accentColor={provider.accentColor} - /> - {notice ? ( - {notice} - ) : ( - - )} - {limits.resetCredits ? ( - - ) : null} -
- ); -} - const OUTCOME_TEXT: Record = { reset: "Reset applied. Your windows have cleared.", nothingToReset: "Nothing to reset right now.", @@ -297,36 +203,12 @@ const OUTCOME_TEXT: Record = { alreadyRedeemed: "That credit was already redeemed.", }; -/** - * Banked reset credits with a confirmed redeem action. Redeeming spends a - * credit the provider granted the user, so it never fires on a bare click. - */ -export function ResetCredits({ - environmentId, - instanceId, - credits, - now, -}: { - readonly environmentId: EnvironmentId; - readonly instanceId: ProviderInstanceId; - readonly credits: ServerProviderResetCredits; - readonly now: number; -}) { +/** Everything a redeem needs: where to send it and what to say afterwards. */ +export function useResetCredit(environmentId: EnvironmentId, instanceId: ProviderInstanceId) { const consume = useAtomCommand(serverEnvironment.consumeResetCredit, { reportFailure: false }); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; - - const expiresIn = credits.nextExpiresAt - ? formatDuration(Date.parse(credits.nextExpiresAt) - now) - : null; - const summary = - credits.availableCount === 0 - ? "No reset credits banked" - : `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ - expiresIn ? ` · next expires in ${expiresIn}` : "" - }`; const redeem = async () => { setConfirming(false); @@ -345,138 +227,115 @@ export function ResetCredits({ ); }; - return ( -
- {summary} - {credits.availableCount > 0 ? ( - - ) : null} - {status ? {status} : null} - - - - Use a reset credit? - - This redeems one credit on your account and clears the current rate-limit windows. It - cannot be undone. - - - - }>Cancel - - - - -
- ); + return { confirming, setConfirming, busy, status, redeem }; } -/** One account pooled by a usage-limit source, drawn like a provider row. */ -function SourceAccountLimits({ - account, - sourceKind, - now, +/** + * The confirm for a redeem. Redeeming spends a credit the provider granted the + * user, so it never fires on a bare click. Mount it outside any popover that + * holds the button: dialogs stack under popovers, and closing the popover + * would unmount a dialog rendered inside it. + */ +export function ResetCreditDialog({ + open, + onOpenChange, + onConfirm, }: { - readonly account: UsageLimitSourceAccount; - readonly sourceKind: string; - readonly now: number; + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly onConfirm: () => void; }) { - const notice = limitsNotice(account.usageLimits); return ( -
- - {notice ? ( - {notice} - ) : ( - - )} -
+ + + + Use a reset credit? + + This redeems one credit on your account and clears the current rate-limit windows. It + cannot be undone. + + + + }>Cancel + + + + ); } -const SOURCE_KIND_LABEL: Record = { - cliproxy: "CLI Proxy", -}; - -type LimitsSource = ReturnType[number]; +/** `2 reset credits banked · next expires in 27d 23h`, or the short form for a popover. */ +export function resetCreditsSummary( + credits: ServerProviderResetCredits, + now: number, + compact = false, +): string { + const expiresIn = credits.nextExpiresAt + ? formatDuration(Date.parse(credits.nextExpiresAt) - now) + : null; + if (credits.availableCount === 0) return "No reset credits banked"; + if (compact) + return `${credits.availableCount} banked${expiresIn ? ` · expires in ${expiresIn}` : ""}`; + return `${credits.availableCount} ${credits.availableCount === 1 ? "reset credit" : "reset credits"} banked${ + expiresIn ? ` · next expires in ${expiresIn}` : "" + }`; +} -/** Read-only accounts pooled by a configured usage source. */ -function SourceLimits({ source, now }: { readonly source: LimitsSource; readonly now: number }) { - const kind = SOURCE_KIND_LABEL[source.kind]; +/** Banked reset credits with the redeem button and its confirm, self-contained. */ +export function ResetCredits({ + environmentId, + instanceId, + credits, + now, +}: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + readonly credits: ServerProviderResetCredits; + readonly now: number; +}) { + const { confirming, setConfirming, busy, status, redeem } = useResetCredit( + environmentId, + instanceId, + ); + if (credits.availableCount === 0 && status === null) return null; return ( -
- {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account) => ( - - )) - )} +
+ {resetCreditsSummary(credits, now)} + {credits.availableCount > 0 ? ( + + ) : null} + {status ? {status} : null} + void redeem()} + />
); } /** - * Subscription quota windows from every connected environment's providers. - * Countdowns anchor to render time rather than ticking: a live clock would - * repaint the page every minute for no decision-changing gain. + * Subscription quota across every connected environment's providers and hubs, + * pooled per provider. Countdowns anchor to render time rather than ticking: a + * live clock would repaint the page every minute for no decision-changing gain. */ export function UsageLimitsSection({ selectedEnvironmentIds, + fixture = null, }: { readonly selectedEnvironmentIds: ReadonlySet | null; + /** Dev-only synthetic presentations standing in for the live ones. */ + readonly fixture?: ReturnType | null; }) { - const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const live = useAtomValue(environmentPresentations.presentationsAtom); + // Anchored once per mount on purpose: countdowns must not tick (see above). + const [now] = useState(() => Date.now()); + const presentations: Parameters[0] = fixture ?? live; const selected = selectedEnvironmentIds === null ? presentations : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); - const groups = collectLimitsGroups(selected); - const sources = collectLimitSources(selected); - // Anchored once per mount on purpose: countdowns must not tick (see below). - const [now] = useState(() => Date.now()); - - return ( -
- {groups.length === 0 && sources.length === 0 ? ( -

- No provider on the selected environments reports subscription limits. -

- ) : null} - {sources.map((source) => ( - - ))} - {groups.map((group) => ( -
- {group.environmentLabel ? ( -

- {group.environmentLabel} -

- ) : null} - {group.providers.map((provider) => ( - - ))} -
- ))} -
- ); + return ; } diff --git a/apps/web/src/components/usage/UsageLimitsPooled.tsx b/apps/web/src/components/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..1d57cdb96340 --- /dev/null +++ b/apps/web/src/components/usage/UsageLimitsPooled.tsx @@ -0,0 +1,570 @@ +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + type LimitAccount, + type LimitPool, + type LimitPoolMember, + type LimitPoolWindow, + remainingPercent, +} from "@t3tools/shared/usageLimits"; +import { TicketIcon } from "lucide-react"; +import { type ReactNode, useState } from "react"; + +import { usePrimarySettings } from "../../hooks/useSettings"; +import { cn } from "../../lib/utils"; +import { formatUpcomingTimestamp } from "../../timestampFormat"; +import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { RedactedSensitiveText } from "../settings/RedactedSensitiveText"; +import { Button } from "../ui/button"; +import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover"; +import { + PaceIcon, + ResetCreditDialog, + barColor, + resetCreditsSummary, + useResetCredit, +} from "./UsageLimits"; + +/** `someone@example.com` → `SE`: enough to tell accounts apart, too little to identify one. */ +function accountInitials(email: string): string { + const [local = "", domain = ""] = email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "?"; +} + +/** A stable hue per email, so the same account gets the same chip on every visit. */ +function accountHue(email: string): number { + let hash = 0; + for (let index = 0; index < email.length; index += 1) { + hash = (hash * 31 + email.charCodeAt(index)) | 0; + } + return Math.abs(hash) % 360; +} + +/** The two-letter chip for an email, coloured by a stable hue per address. */ +function AccountChip({ email }: { readonly email: string }) { + const hue = accountHue(email); + return ( + + {accountInitials(email)} + + ); +} + +/** + * The same mark the model picker uses for a native instance (provider glyph, + * initials badge, accent); hub accounts have no instance, so they get the chip. + */ +function AccountAvatar({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.redeem) { + return ( + + ); + } + return account.email ? : null; +} + +/** + * Who an account is, without printing the email: the instance name when there + * is one, else a two-letter chip. The address itself is revealed on demand in + * the segment's popover. + */ +function AccountName({ + account, + className, +}: { + readonly account: LimitAccount; + readonly className?: string; +}) { + if (account.displayName) return {account.displayName}; + if (account.email) { + return ( + + + + ); + } + return ( + + {getDriverOption(account.driver)?.label ?? String(account.driver)} + + ); +} + +function Row({ label, children }: { readonly label: string; readonly children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +/** + * Everything about one account in one window: plan, where it is signed in, + * the email on request, reset time and share of the pool it restores, and the + * reset-credit action. Opens on hover for a glance, on click to act. + */ +function SegmentPopover({ + account, + window, + reset, + now, + redeem, + onRedeem, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + /** Redeem state owned by the segment, since the confirm lives outside this popover. */ + readonly redeem: ReturnType | null; + readonly onRedeem: () => void; +}) { + const timestampFormat = usePrimarySettings((settings) => settings.timestampFormat); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const where = + account.environments.length > 0 + ? account.environments.map((environment) => environment.label).join(", ") + : account.sourceLabel; + const credits = + redeem && account.limits.resetCredits?.availableCount ? account.limits.resetCredits : null; + return ( +
+
+ + + + {account.displayName ?? getDriverOption(account.driver)?.label ?? account.driver} + + + {account.email ? ( + + ) : null} +
+
+ {account.plan ? {account.plan} : null} + {where ? ( + 0 ? "Signed in" : "Via"}>{where} + ) : null} +
+
+ {remaining}% + {window.resetsAt ? ( + + {formatUpcomingTimestamp(window.resetsAt, timestampFormat, now)} + {resetsIn ? ` · ${resetsIn.replace("resets in ", "in ")}` : ""} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + +{reset.restoresPercent}% of pool + ) : null} +
+ {credits && redeem ? ( +
+ + {resetCreditsSummary(credits, now, true)} + + +
+ ) : null} +
+ ); +} + +/** + * One account's share of one pooled window: the segment, its popover, and the + * reset confirm. The confirm is a sibling of the popover, not a child: dialogs + * stack under popovers, and the popover closes as the confirm opens. + */ +function PoolSegment({ + account, + window, + reset, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly color: string; + readonly now: number; + /** 1-based position in the bar, shown on the strip and its legend row to tie them together. */ + readonly index: number; +}) { + const [open, setOpen] = useState(false); + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + } + > + {/* Translucent so the label reads over the fill for any provider colour and theme. */} +
+ {/* The spent share is hatched, not blank: it is what the countdown restores. */} + {remaining < 100 && reset ? ( +
+ ) : null} + + {index} + +
+ + {remaining}% + {/* Countdown and badge get their own plate: fill and hatching run under them otherwise. */} + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? ( + + · + + ) : null} + + + {credits} + + + ) : null} + +
+ + + {account.redeem ? ( + setOpen(false)} + /> + ) : ( + + {}} + /> + + )} + + ); +} + +/** + * Below the strip at narrow widths: one row per account in bar order, carrying + * the text the segment has no room for. Tapping a row opens the same popover + * as its segment, so the two are one control with two handles. + */ +function LegendRow({ + account, + window, + color, + now, + index, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly color: string; + readonly now: number; + readonly index: number; +}) { + const remaining = remainingPercent(window); + const resetsIn = formatResetsIn(window, now); + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + return ( + + + + Segment + {index} + + + {remaining}% + + {resetsIn?.replace("resets in ", "↻ ") ?? ""} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + {credits} reset {credits === 1 ? "credit" : "credits"} banked + + + ) : null} + + + ); +} + +/** Split out so the redeem hook only runs for accounts that can redeem. */ +function RedeemableSegmentPopup({ + account, + window, + reset, + now, + redeemAt, + closePopover, +}: { + readonly account: LimitAccount; + readonly window: LimitPoolMember["window"]; + readonly reset: LimitPoolWindow["resets"][number] | undefined; + readonly now: number; + readonly redeemAt: NonNullable; + readonly closePopover: () => void; +}) { + const redeem = useResetCredit(redeemAt.environmentId, redeemAt.instanceId); + return ( + <> + + { + closePopover(); + redeem.setConfirming(true); + }} + /> + + void redeem.redeem()} + /> + {/* The popover closed before the confirm, so the outcome needs a home outside it. */} + {redeem.status ? ( + + {redeem.status} + + ) : null} + + ); +} + +/** + * One pooled window as equal-width segments, one per account, each filled by + * the share of that account's quota still open. Equal widths are honest: every + * account contributes the same share of the pool, whatever its plan. + * + * Wide, each segment carries its own label. Narrow, the bar is a bare strip + * and a legend below lists the accounts in the same order; both open the + * same popover. + */ +function PoolBar({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + const restores = new Map(pool.resets.map((reset) => [reset.member.account.key, reset])); + return ( +
+
+ {pool.members.map(({ account, window }, position) => ( + + ))} +
+
+ ); +} + +/** + * Big pooled number and the segment bar. The bar is sorted by reset, so who + * refills next is its left edge; the exact time and share restored live in + * each segment's popover rather than a list restating the bar. + */ +function PoolWindowCard({ + pool, + color, + now, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; +}) { + // The soonest reset that hands anything back; an untouched account resets to no effect. + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + return ( +
+
+ {pool.label} + + + {pool.remainingPercent}% + + left + {pool.pace ? : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} +
+ +
+ ); +} + +function PoolSection({ pool, now }: { readonly pool: LimitPool; readonly now: number }) { + const color = barColor(pool.driver); + const label = getDriverOption(pool.driver)?.label ?? String(pool.driver); + return ( +
+

+ + {label} +

+ {pool.windows.map((window) => ( + + ))} +
+ ); +} + +/** + * Accounts pooled per provider: what is open across all of them, who resets + * next, and how much of the pool that hands back. Answers "can I keep going" + * before "on which account". + */ +export function UsageLimitsPooled({ + presentations, + now, +}: { + readonly presentations: Parameters[0]; + readonly now: number; +}) { + const pools = collectLimitPools(collectLimitAccounts(presentations), now); + const notices = collectLimitNotices(presentations); + return ( +
+ {pools.length === 0 ? ( +

+ No provider on the selected environments reports subscription limits. +

+ ) : null} + {pools.map((pool) => ( + + ))} + +
+ ); +} + +/** Sources and providers that could not be read, so a missing bar is not mistaken for a full one. */ +function LimitNotices({ notices }: { readonly notices: readonly string[] }) { + if (notices.length === 0) return null; + return ( +
    + {notices.map((notice) => ( +
  • {notice}
  • + ))} +
+ ); +} diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index deb05f266b98..066f8549f5e9 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -59,6 +59,7 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; +import { makeLimitsFixture } from "./usageLimitsFixture"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -109,10 +110,33 @@ export function UsagePage() { useState | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( - window, - selectedEnvironmentIds, - ); + const usage = useUsage(window, selectedEnvironmentIds); + // Dev only: `/usage?limitsFixture=` lists synthetic environments in the + // picker and feeds the Limits view from them, so merge rules can be eyeballed. + const [fixture] = useState(() => { + if (!import.meta.env.DEV) return null; + const name = new URLSearchParams(globalThis.location.search).get("limitsFixture"); + return name ? makeLimitsFixture(name, Date.now()) : null; + }); + const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = + useMemo(() => { + if (!fixture || !showingLimits) return usage; + const all = [...fixture].map(([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + isPending: false, + error: null, + summary: null, + })); + return { + ...usage, + environments: all, + selectedEnvironments: + selectedEnvironmentIds === null + ? all + : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)), + }; + }, [fixture, selectedEnvironmentIds, showingLimits, usage]); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, @@ -166,6 +190,8 @@ export function UsagePage() { if (refreshingRef.current) return; if (showingLimits) { + // Synthetic data has nothing to re-read. + if (fixture) return; refreshingRef.current = true; setIsRefreshing(true); void Promise.all( @@ -349,7 +375,10 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( diff --git a/apps/web/src/components/usage/usageLimitsFixture.ts b/apps/web/src/components/usage/usageLimitsFixture.ts new file mode 100644 index 000000000000..8958707b98de --- /dev/null +++ b/apps/web/src/components/usage/usageLimitsFixture.ts @@ -0,0 +1,413 @@ +/** + * Dev-only stand-ins for `environmentPresentations` that exercise the pooled + * Limits view's merge rules, one scenario per named fixture. Reached with + * `/usage?limitsFixture=` on a dev build; never bundled otherwise. + */ +import { + EnvironmentId, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, + type ServerProviderUsageWindow, + type UsageLimitSourceSnapshot, + UsageLimitSourceId, +} from "@t3tools/contracts"; + +const MINUTE = 60_000; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +interface Presentation { + readonly entry: { readonly target: { readonly label: string } }; + readonly serverConfig: { + readonly providers: readonly ServerProvider[]; + readonly usageLimitSources: readonly UsageLimitSourceSnapshot[]; + }; +} + +type Fixture = ReadonlyMap; + +const codex = ProviderDriverKind.make("codex"); +const claude = ProviderDriverKind.make("claudeAgent"); + +function makeHelpers(now: number) { + const at = (ms: number) => new Date(now + ms).toISOString(); + const checked = (agoMs: number) => new Date(now - agoMs).toISOString(); + + const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ + id: "five_hour", + kind: "session", + label: "Session", + usedPercent: used, + windowDurationMins: 300, + resetsAt: at(resetsInMs), + }); + const weekly = ( + id: string, + label: string, + used: number, + resetsInMs: number, + ): ServerProviderUsageWindow => ({ + id, + kind: "weekly", + label, + usedPercent: used, + windowDurationMins: 7 * 24 * 60, + resetsAt: at(resetsInMs), + }); + /** Codex names its five-hour window `primary` and its weekly one `secondary`. */ + const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ + ...session(used, resetsInMs), + id: "primary", + }); + const codexWeekly = (used: number, resetsInMs: number) => + weekly("secondary", "Weekly", used, resetsInMs); + + const provider = ( + overrides: Partial & Pick, + ): ServerProvider => ({ + enabled: true, + installed: true, + version: null, + status: "ready", + auth: { status: "authenticated" }, + checkedAt: checked(0), + models: [], + slashCommands: [], + skills: [], + ...overrides, + }); + + const codexInstance = (input: { + readonly instanceId: string; + readonly displayName?: string; + readonly accentColor?: string; + readonly email: string; + readonly plan?: string; + readonly checkedAgoMs?: number; + readonly windows: readonly ServerProviderUsageWindow[]; + readonly credits?: number; + }) => + provider({ + instanceId: ProviderInstanceId.make(input.instanceId), + driver: codex, + ...(input.displayName ? { displayName: input.displayName } : {}), + ...(input.accentColor ? { accentColor: input.accentColor } : {}), + auth: { + status: "authenticated", + label: input.plan ?? "ChatGPT Pro 20x Subscription", + email: input.email, + }, + usageLimits: { + checkedAt: checked(input.checkedAgoMs ?? MINUTE), + windows: input.windows, + ...(input.credits + ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } } + : {}), + }, + }); + + const claudeHubAccount = ( + email: string | null, + windows: readonly ServerProviderUsageWindow[], + checkedAgoMs = 4 * MINUTE, + ): UsageLimitSourceSnapshot["accounts"][number] => ({ + id: email ? `claude-${email}.json` : "claude-team-seat.json", + driver: claude, + ...(email ? { email } : {}), + plan: "Claude Subscription", + usageLimits: { checkedAt: checked(checkedAgoMs), windows }, + }); + + const hub = ( + id: string, + label: string, + accounts: UsageLimitSourceSnapshot["accounts"], + error?: string, + ): UsageLimitSourceSnapshot => ({ + id: UsageLimitSourceId.make(id), + kind: "cliproxy", + label, + checkedAt: checked(2 * MINUTE), + accounts, + ...(error ? { error } : {}), + }); + + const environment = ( + id: string, + label: string, + providers: readonly ServerProvider[], + usageLimitSources: readonly UsageLimitSourceSnapshot[] = [], + ): readonly [EnvironmentId, Presentation] => [ + EnvironmentId.make(id), + { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } }, + ]; + + return { + at, + checked, + session, + weekly, + codexSession, + codexWeekly, + provider, + codexInstance, + claudeHubAccount, + hub, + environment, + }; +} + +const FIXTURES: Record Fixture> = { + /** + * The same Codex account signed in on two machines with different snapshot + * ages, plus a hub that also reports it. Must collapse to one segment with + * the freshest figures and both machines listed. + */ + "same-account": (now) => { + const h = makeHelpers(now); + const email = "main@example.com"; + const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ + { + id: `codex-abc-${email}-pro.json`, + driver: codex, + email, + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] }, + }, + ]; + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email, + windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], + credits: 2, + }), + ], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment("env-nucbox", "nucbox-1", [ + h.codexInstance({ + instanceId: "codex", + email, + checkedAgoMs: 9 * MINUTE, + windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)], + credits: 2, + }), + ]), + ]); + }, + + /** + * Three machines, no two alike: one has only Codex, one only Claude via a + * hub, one has both natively. Filtering to any single environment should + * drop whole provider sections. + */ + "uneven-environments": (now) => { + const h = makeHelpers(now); + return new Map([ + h.environment("env-macbook", "MacBook Pro", [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email: "main@example.com", + windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], + credits: 2, + }), + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [ + h.session(2, 4 * HOUR), + h.weekly("seven_day", "Weekly", 38, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), + ], + }, + }), + ]), + h.environment( + "env-nucbox", + "nucbox-1", + [], + [ + h.hub("cliproxy-nucbox", "CLI Proxy", [ + h.claudeHubAccount("personal@example.com", [ + h.session(100, 4 * HOUR), + h.weekly("seven_day", "Weekly", 50, DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY), + ]), + h.claudeHubAccount("second@example.org", [ + h.session(63, 2 * HOUR), + h.weekly("seven_day", "Weekly", 37, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY), + ]), + ]), + ], + ), + h.environment("env-macmini", "Mac Mini", [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Work", + email: "work@example.com", + windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)], + credits: 1, + }), + ]), + ]); + }, + + /** + * Codex plans that report only one window (Go reports a monthly allowance; + * a hub often has no five-hour figure for an account) mixed with a plan that + * reports both. Each pool lists only the accounts that have that window. + */ + "codex-window-mix": (now) => { + const h = makeHelpers(now); + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.codexInstance({ + instanceId: "codex", + displayName: "Codex Personal", + accentColor: "#6366f1", + email: "main@example.com", + windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)], + credits: 2, + }), + h.codexInstance({ + instanceId: "codex-go", + displayName: "Codex Go", + accentColor: "#10b981", + email: "go@example.com", + plan: "ChatGPT Go Subscription", + windows: [ + { + id: "primary", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: h.at(11 * DAY), + }, + ], + }), + ], + [ + h.hub("cliproxy-nucbox", "CLI Proxy", [ + { + id: "codex-def-work@example.com-pro.json", + driver: codex, + email: "work@example.com", + plan: "ChatGPT Pro 20x Subscription", + usageLimits: { + checkedAt: h.checked(3 * MINUTE), + windows: [h.codexWeekly(88, 6 * DAY)], + }, + }, + { + id: "codex-ghi-team@example.net-plus.json", + driver: codex, + email: "team@example.net", + plan: "ChatGPT Plus Subscription", + usageLimits: { + checkedAt: h.checked(3 * MINUTE), + windows: [h.codexWeekly(12, DAY)], + }, + }, + ]), + ], + ), + ]); + }, + + /** + * A hub configured on two environments, a hub that is down, a provider + * whose probe failed, an API-key account, and a hub account with no email. + */ + "failures-and-strays": (now) => { + const h = makeHelpers(now); + const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ + h.claudeHubAccount("main@example.com", [ + h.session(2, 4 * HOUR), + h.weekly("seven_day", "Weekly", 38, 4 * DAY), + h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), + ]), + h.claudeHubAccount(null, [ + h.session(40, 2 * HOUR), + h.weekly("seven_day", "Weekly", 20, 6 * DAY), + ]), + ]; + return new Map([ + h.environment( + "env-macbook", + "MacBook Pro", + [ + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { status: "authenticated", label: "Claude API Key" }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [], + unavailable: { + reason: "unsupported", + message: "This account has no subscription limits.", + }, + }, + }), + ], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment( + "env-nucbox", + "nucbox-1", + [], + [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], + ), + h.environment( + "env-macmini", + "Mac Mini", + [ + h.provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + auth: { + status: "authenticated", + label: "Claude Max", + email: "work@example.com", + }, + usageLimits: { + checkedAt: h.checked(MINUTE), + windows: [], + unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." }, + }, + }), + ], + [ + h.hub( + "cliproxy-aws", + "AWS proxy", + [], + "fetch failed: connect ECONNREFUSED 10.0.0.4:8318", + ), + ], + ), + ]); + }, +}; + +export function makeLimitsFixture(name: string, now: number): Fixture | null { + return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null; +} diff --git a/docs/user/usage.md b/docs/user/usage.md index 4e4196a46337..2f3e1013fdce 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,9 +39,18 @@ the dialog. ## Track subscription limits -**Usage → Limits** shows how much quota is left in each window and when it resets, for Codex and -Claude subscriptions. For windows with timing data, each bar also marks how much of the window is -left, so you can judge your pace before the next reset. +On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex +or Claude accounts across your environments and hubs you read one number per window rather than a +list. Each window card shows how much of the pool is left and a bar with one segment per account, +ordered by which resets soonest; when the provider reports reset times, the card also says when +the next reset lands and how much it hands back. The hatched +part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is +signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the +segment and the **Use reset** action in that popover. On narrow screens, numbered rows below +the bar show each account's quota, countdown, and credits. Tap a row to open its details. + +The same account signed in on more than one environment, or reported by a hub as well, counts once. +Filter with the environment dropdown to see what a single machine has. If a window looks stale, refresh Limits to re-check every provider and hub. diff --git a/packages/shared/src/usageLimits.test.ts b/packages/shared/src/usageLimits.test.ts index fede8813e913..32fb4eaa9503 100644 --- a/packages/shared/src/usageLimits.test.ts +++ b/packages/shared/src/usageLimits.test.ts @@ -13,6 +13,9 @@ import { collectProviderUsageLimits, sameUsageLimitCommandCoverage, withUsageLimitsCommands, + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, collectLimitSources, collectLimitsGroups, elapsedShare, @@ -310,6 +313,381 @@ describe("collectLimitSources", () => { }); }); +describe("pools", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const weekly = { + id: "seven_day", + kind: "weekly", + label: "Weekly", + windowDurationMins: 7 * 24 * 60, + resetsAt: "2026-09-06T12:00:00.000Z", + } as const; + const claude = ProviderDriverKind.make("claudeAgent"); + const source = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + }; + const laptop = { entry: { target: { label: "Laptop" } } }; + + it("merges one account reported natively on two environments and by a hub into one entry", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "Same@example.com" }, + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 40 }] }, + }); + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [native] } }], + [ + EnvironmentId.make("env-b"), + { + entry: { target: { label: "Desktop" } }, + serverConfig: { + providers: [ + { + ...native, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + plan: "Claude Subscription", + usageLimits: { checkedAt, windows: [{ ...window, usedPercent: 10 }] }, + }, + ], + }, + ], + }, + }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts).toHaveLength(1); + expect(accounts[0]).toMatchObject({ + key: "env-a:claude", + sourceLabel: null, + // Desktop's read is fresher, so its credits and its redeem are the ones on show. + redeem: { environmentId: "env-b", instanceId: "claude" }, + environments: [ + { environmentId: "env-a", label: "Laptop" }, + { environmentId: "env-b", label: "Desktop" }, + ], + }); + // The fresher native snapshot wins; the hub row is pre-filtered by email. + expect(accounts[0]?.limits.windows[0]?.usedPercent).toBe(55); + }); + + it("takes windows from a fresher hub read but credits and redeem from the native instance", () => { + const native = provider({ + driver: claude, + instanceId: ProviderInstanceId.make("claude"), + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + resetCredits: { availableCount: 2 }, + }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [native], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "claude-same@example.com.json", + driver: claude, + email: "same@example.com", + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [{ ...window, usedPercent: 55 }], + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.windows[0]?.usedPercent).toBe(55); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-a", instanceId: "claude" }); + expect(account?.environments).toEqual([{ environmentId: "env-a", label: "Laptop" }]); + }); + + it("redeems on the environment whose snapshot supplied the credits on show", () => { + const stale = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { + checkedAt, + windows: [window], + resetCredits: { availableCount: 0 }, + }, + }); + const fresh = { + ...stale, + usageLimits: { + checkedAt: "2026-09-03T11:30:00.000Z", + windows: [window], + resetCredits: { availableCount: 2 }, + }, + }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { providers: [stale] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { providers: [fresh] } }, + ], + ]); + const [account] = collectLimitAccounts(input); + expect(account?.limits.resetCredits?.availableCount).toBe(2); + expect(account?.redeem).toEqual({ environmentId: "env-b", instanceId: "codex" }); + }); + + it("names an environment once however many of its instances share the account", () => { + const shared = provider({ + auth: { status: "authenticated", email: "same@example.com" }, + usageLimits: { checkedAt, windows: [window] }, + }); + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [shared, { ...shared, instanceId: ProviderInstanceId.make("work") }], + }, + }, + ], + ]); + expect(collectLimitAccounts(input)[0]?.environments).toEqual([ + { environmentId: "env-a", label: "Laptop" }, + ]); + }); + + it("keys a hub account without an email by hub, so two environments on one hub share it", () => { + const seat = { + id: "claude-team-seat.json", + driver: claude, + usageLimits: { checkedAt, windows: [window] }, + }; + const hub = { ...source, accounts: [seat] }; + const input = new Map([ + [EnvironmentId.make("env-a"), { ...laptop, serverConfig: { usageLimitSources: [hub] } }], + [ + EnvironmentId.make("env-b"), + { entry: { target: { label: "Desktop" } }, serverConfig: { usageLimitSources: [hub] } }, + ], + ]); + const accounts = collectLimitAccounts(input); + expect(accounts.map((account) => account.key)).toEqual(["hub:claude-team-seat.json"]); + expect(accounts[0]?.displayName).toBe("claude-team-seat"); + }); + + it("pools windows by id across accounts and orders resets by when they land", () => { + const input = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [], + usageLimitSources: [ + { + ...source, + accounts: [ + { + id: "a", + driver: claude, + usageLimits: { + checkedAt, + windows: [ + { ...window, usedPercent: 80, resetsAt: "2026-09-03T13:00:00.000Z" }, + { ...weekly, usedPercent: 20 }, + ], + }, + }, + { + id: "b", + driver: claude, + usageLimits: { + checkedAt, + windows: [{ ...window, usedPercent: 40 }], + }, + }, + { + id: "c", + driver: ProviderDriverKind.make("codex"), + usageLimits: { checkedAt, windows: [{ ...weekly, usedPercent: 50 }] }, + }, + { + id: "unsupported", + driver: claude, + usageLimits: { + checkedAt, + windows: [], + unavailable: { reason: "unsupported" as const }, + }, + }, + ], + }, + ], + }, + }, + ], + ]); + const pools = collectLimitPools(collectLimitAccounts(input), now); + expect(pools.map((pool) => [pool.driver, pool.accounts.length])).toEqual([ + ["claudeAgent", 2], + ["codex", 1], + ]); + const [session, week] = pools[0]!.windows; + // A member with no reset has no clock, so it does not vote on pace. + const untimed = collectLimitPools( + collectLimitAccounts(input).map((account) => + account.key === "hub:b" + ? { + ...account, + limits: { + ...account.limits, + windows: account.limits.windows.map((w) => ({ ...w, resetsAt: undefined })), + }, + } + : account, + ), + now, + ); + // Only a votes: 80% used, 80% elapsed. + expect(untimed[0]?.windows[0]?.pace).toBe("on"); + // a is 80% through its window and b 60%: the pool is 70% elapsed, 60% used. + expect(session).toMatchObject({ + id: "five_hour", + remainingPercent: 40, + usedPercent: 60, + pace: "under", + }); + expect( + session?.resets.map((reset) => [reset.member.account.key, reset.restoresPercent]), + ).toEqual([ + ["hub:a", 40], + ["hub:b", 20], + ]); + expect(week).toMatchObject({ id: "seven_day", remainingPercent: 80, members: [{}] }); + // Codex reports `primary` for both its five-hour and (on Go) monthly window. + const mixed = collectLimitPools( + [ + ...collectLimitAccounts(input), + { + key: "go", + driver: claude, + displayName: "Go", + email: undefined, + plan: undefined, + accentColor: undefined, + environments: [], + sourceLabel: null, + redeem: null, + limits: { + checkedAt, + windows: [ + { + id: "five_hour", + kind: "monthly", + label: "Monthly", + usedPercent: 82, + windowDurationMins: 30 * 24 * 60, + resetsAt: "2026-09-14T12:00:00.000Z", + }, + ], + }, + }, + ], + now, + ); + expect(mixed[0]?.windows.map((window) => [window.kind, window.members.length])).toEqual([ + ["session", 2], + ["weekly", 1], + ["monthly", 1], + ]); + // Segments read left to right as "who refills next", matching the reset list. + expect(session?.members.map((member) => member.account.key)).toEqual(["hub:a", "hub:b"]); + expect(pools[0]?.accounts.map((account) => account.key)).toEqual(["hub:a", "hub:b"]); + }); +}); + +describe("collectLimitNotices", () => { + const checkedAt = "2026-09-03T11:00:00.000Z"; + const claude = ProviderDriverKind.make("claudeAgent"); + const laptop = { entry: { target: { label: "Laptop" } } }; + const hub = { + id: UsageLimitSourceId.make("hub"), + kind: "cliproxy" as const, + label: "hub", + checkedAt, + accounts: [], + }; + + it("names failures and silence, skips unsupported accounts, and labels environments only when several", () => { + const failed = provider({ + instanceId: ProviderInstanceId.make("claude"), + driver: claude, + displayName: "Claude Max", + usageLimits: { checkedAt, windows: [], unavailable: { reason: "probeFailed" } }, + }); + const apiKey = provider({ + instanceId: ProviderInstanceId.make("api"), + driver: claude, + usageLimits: { checkedAt, windows: [], unavailable: { reason: "unsupported" } }, + }); + const silent = provider({ usageLimits: { checkedAt, windows: [] } }); + const one = new Map([ + [ + EnvironmentId.make("env-a"), + { + ...laptop, + serverConfig: { + providers: [failed, apiKey, silent], + usageLimitSources: [ + hub, + { ...hub, id: UsageLimitSourceId.make("down"), label: "down", error: "ECONNREFUSED" }, + ], + }, + }, + ], + ]); + expect(collectLimitNotices(one)).toEqual([ + "Claude Max: Could not read limits.", + "codex: No limits reported.", + "hub: No accounts reported.", + "down: ECONNREFUSED", + ]); + + one.set(EnvironmentId.make("env-b"), { + entry: { target: { label: "Desktop" } }, + serverConfig: { providers: [], usageLimitSources: [] }, + }); + expect(collectLimitNotices(one)[0]).toBe("Laptop · Claude Max: Could not read limits."); + }); +}); + describe("/usage-limits", () => { const limits = { checkedAt: "2026-09-03T11:00:00.000Z", windows: [window] }; const selected = provider({ diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 5cb5303320bd..419e1944c538 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -58,7 +58,9 @@ export function collectLimitsGroups( EnvironmentId, { readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { readonly providers: readonly ServerProvider[] } | null; + readonly serverConfig: { + readonly providers?: readonly ServerProvider[] | undefined; + } | null; } >, ): readonly LimitsGroup[] { @@ -147,6 +149,302 @@ function accountKey(driver: ServerProvider["driver"], email: string | undefined) return normalizedEmail ? `${driver}:${normalizedEmail}` : null; } +/** + * One subscription account as the pooled views see it, whichever way it was + * reported. The same email signed in natively on two environments, or reported + * by a hub as well as natively, is one account: its quota is one bucket, so + * counting it twice would misstate what is left. + */ +export interface LimitAccount { + readonly key: string; + readonly driver: ServerProvider["driver"]; + /** The instance's configured name, which is not sensitive; null for hub accounts. */ + readonly displayName: string | null; + readonly email: string | undefined; + readonly plan: string | undefined; + readonly accentColor: string | undefined; + /** Environments the account is signed in on; empty when only a hub reports it. */ + readonly environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly label: string; + }>; + /** The hub that reported it, when no environment has it natively. */ + readonly sourceLabel: string | null; + /** Where a reset credit can be redeemed; only native instances can. */ + readonly redeem: { + readonly environmentId: EnvironmentId; + readonly instanceId: ProviderInstanceId; + } | null; + readonly limits: ServerProviderUsageLimits; +} + +/** + * Every account with usable windows across the connected environments, one + * entry per distinct account. Native instances win over hub reports, and the + * freshest snapshot wins when the same account is reported twice. + */ +export function collectLimitAccounts( + presentations: Parameters[0], +): readonly LimitAccount[] { + const accounts = new Map(); + const merge = (key: string, next: LimitAccount) => { + const previous = accounts.get(key); + if (!previous) { + accounts.set(key, next); + return; + } + const fresher = Date.parse(next.limits.checkedAt) > Date.parse(previous.limits.checkedAt); + // Two instances on one machine sharing an account still name it once. + const environments = [ + ...previous.environments, + ...next.environments.filter( + (candidate) => + !previous.environments.some((seen) => seen.environmentId === candidate.environmentId), + ), + ]; + const winner = fresher ? next : previous; + // Windows come from the freshest snapshot, wherever it was read. Reset + // credits only ever come from a native instance, and the redeem must go + // to the instance whose credits are on show, so the two travel together: + // the freshest native snapshot supplies both, or neither. + const native = [previous, next] + .filter((candidate) => candidate.redeem !== null) + .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + accounts.set(key, { + ...previous, + displayName: previous.displayName ?? next.displayName, + plan: previous.plan ?? next.plan, + accentColor: previous.accentColor ?? next.accentColor, + environments, + // A hub only names the account when no environment has it natively. + sourceLabel: environments.length > 0 ? null : (previous.sourceLabel ?? next.sourceLabel), + redeem: native?.redeem ?? null, + limits: { + ...winner.limits, + ...(native?.limits.resetCredits + ? { resetCredits: native.limits.resetCredits } + : { resetCredits: undefined }), + }, + }); + }; + for (const [environmentId, presentation] of presentations) { + const label = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + if (!provider.usageLimits || limitsNotice(provider.usageLimits) !== null) continue; + merge( + accountKey(provider.driver, provider.auth.email) ?? + `${environmentId}:${provider.instanceId}`, + { + key: `${environmentId}:${provider.instanceId}`, + driver: provider.driver, + displayName: provider.displayName?.trim() || null, + email: provider.auth.email, + plan: provider.auth.label, + accentColor: provider.accentColor, + environments: [{ environmentId, label }], + sourceLabel: null, + redeem: { environmentId, instanceId: provider.instanceId }, + limits: provider.usageLimits, + }, + ); + } + } + // Every hub account, including those a native instance also knows: the hub + // may hold a fresher read of the same subscription, and the merge above + // keeps the redeem target consistent with whichever snapshot wins. + const labelEnvironment = presentations.size > 1; + for (const presentation of presentations.values()) { + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + const sourceLabel = labelEnvironment + ? `${presentation.entry.target.label} · ${source.label}` + : source.label; + for (const account of source.accounts) { + if (limitsNotice(account.usageLimits) !== null) continue; + merge(accountKey(account.driver, account.email) ?? `${source.id}:${account.id}`, { + key: `${source.id}:${account.id}`, + driver: account.driver, + displayName: account.email ? null : account.id.replace(/\.json$/i, ""), + email: account.email, + plan: account.plan, + accentColor: undefined, + environments: [], + sourceLabel, + redeem: null, + limits: account.usageLimits, + }); + } + } + } + return [...accounts.values()]; +} + +/** + * What the pooled views cannot draw as a bar: a hub that failed to read, a + * provider whose probe failed. Accounts that can never report (API keys) + * are left out; there is nothing for the user to act on. The environment + * is named only when more than one is connected. + */ +export function collectLimitNotices( + presentations: Parameters[0], +): readonly string[] { + const label = (environmentLabel: string, subject: string) => + presentations.size > 1 ? `${environmentLabel} · ${subject}` : subject; + const notices: string[] = []; + for (const presentation of presentations.values()) { + const environmentLabel = presentation.entry.target.label; + for (const provider of providersWithLimits(presentation.serverConfig?.providers ?? [])) { + // An account that can never report (API key) is left out; one that + // failed, or reported nothing at all, is worth a line. + if (provider.usageLimits?.unavailable?.reason === "unsupported") continue; + const notice = provider.usageLimits ? limitsNotice(provider.usageLimits) : null; + const name = provider.displayName?.trim() || String(provider.driver); + if (notice) notices.push(`${label(environmentLabel, name)}: ${notice}`); + } + for (const source of presentation.serverConfig?.usageLimitSources ?? []) { + if (source.error) { + notices.push(`${label(environmentLabel, source.label)}: ${source.error}`); + } else if (source.accounts.length === 0) { + notices.push(`${label(environmentLabel, source.label)}: No accounts reported.`); + } + } + } + return notices; +} + +export interface LimitPoolMember { + readonly account: LimitAccount; + readonly window: ServerProviderUsageWindow; +} + +/** + * One window id across every account that reports it: the pooled share left, + * pace against the clock, and the resets in the order they will land, each + * with the share of the pool it hands back. + */ +export interface LimitPoolWindow { + readonly id: string; + readonly kind: ServerProviderUsageWindow["kind"]; + readonly label: string; + readonly members: readonly LimitPoolMember[]; + readonly remainingPercent: number; + readonly usedPercent: number; + readonly pace: LimitPace | null; + readonly resets: ReadonlyArray<{ + readonly member: LimitPoolMember; + readonly at: number; + /** Points of the pool the reset restores: the member's used share over the member count. */ + readonly restoresPercent: number; + }>; +} + +export interface LimitPool { + readonly driver: ServerProvider["driver"]; + readonly accounts: readonly LimitAccount[]; + readonly windows: readonly LimitPoolWindow[]; +} + +const WINDOW_KIND_ORDER: Record = { + session: 0, + weekly: 1, + monthly: 2, + other: 3, +}; + +/** + * Accounts grouped by driver, each with its windows pooled by kind and id. + * Window ids are stable per provider, so a hub row and a native row for the + * same window land in the same pool; the kind is part of the key because + * Codex's `primary` is a position, not a duration (five hours on paid plans, + * a month on Free/Go), and a monthly allowance must not average into a + * five-hour pool. Pools order by kind, then first appearance. + * + * `accounts` is the table order: instances the user can act on (native, + * named) before hub-only accounts, each group alphabetical. Each window's + * `members` sort by reset instead, soonest first, so a bar reads left to + * right as "who refills next" and matches the reset list under it. + */ +export function collectLimitPools( + accounts: readonly LimitAccount[], + now: number, +): readonly LimitPool[] { + const byDriver = new Map(); + for (const account of accounts) { + const list = byDriver.get(account.driver); + if (list) list.push(account); + else byDriver.set(account.driver, [account]); + } + return [...byDriver].map(([driver, members]) => { + const sorted = members.toSorted( + (left, right) => + Number(left.redeem === null) - Number(right.redeem === null) || + accountSortName(left).localeCompare(accountSortName(right)), + ); + return { driver, accounts: sorted, windows: poolWindows(sorted, now) }; + }); +} + +function accountSortName(account: LimitAccount): string { + return (account.displayName ?? account.email ?? account.key).toLowerCase(); +} + +function poolWindows(accounts: readonly LimitAccount[], now: number): readonly LimitPoolWindow[] { + const byKey = new Map(); + for (const account of accounts) { + for (const window of account.limits.windows) { + const key = `${window.kind}:${window.id}`; + const list = byKey.get(key); + if (list) list.push({ account, window }); + else byKey.set(key, [{ account, window }]); + } + } + const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { + const members = unordered.toSorted( + (left, right) => + (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - + (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), + ); + const first = members[0]!.window; + const usedPercent = members.reduce((sum, m) => sum + m.window.usedPercent, 0) / members.length; + // Pace compares spend against the clock, so it is judged only over the + // members that have a clock; a window with no reset would otherwise + // count as spend with no time elapsed and skew the verdict. + const timed = members.flatMap((m) => { + const share = elapsedShare(m.window, now); + return share === null ? [] : [{ used: m.window.usedPercent, elapsed: share }]; + }); + const timedUsed = timed.reduce((sum, t) => sum + t.used, 0) / timed.length; + const meanElapsed = + timed.length > 0 ? timed.reduce((sum, t) => sum + t.elapsed, 0) / timed.length : null; + const resets = members + .flatMap((member) => { + const at = resetMillis(member.window); + return at === null + ? [] + : [ + { + member, + at, + restoresPercent: Math.round(member.window.usedPercent / members.length), + }, + ]; + }) + .toSorted((left, right) => left.at - right.at); + return { + id: first.id, + kind: first.kind, + label: first.label, + members, + usedPercent: Math.round(usedPercent), + remainingPercent: Math.round(100 - usedPercent), + pace: meanElapsed === null ? null : paceOfShares(timedUsed, meanElapsed), + resets, + }; + }); + return pools.toSorted( + (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], + ); +} + /** The instance's configured name, else the driver's, else its raw kind. */ export function providerLimitsLabel( provider: Pick, @@ -195,8 +493,11 @@ export type LimitPace = "ahead" | "on" | "under"; */ export function paceOf(window: ServerProviderUsageWindow, now: number): LimitPace | null { const elapsed = elapsedShare(window, now); - if (elapsed === null) return null; - const gap = window.usedPercent - elapsed * 100; + return elapsed === null ? null : paceOfShares(window.usedPercent, elapsed); +} + +function paceOfShares(usedPercent: number, elapsed: number): LimitPace { + const gap = usedPercent - elapsed * 100; if (gap > 5) return "ahead"; if (gap < -5) return "under"; return "on"; From 00d6109cbb1a13712d7347701969870aee83252d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 00:07:04 -0700 Subject: [PATCH 197/320] chore(web): remove usage limits demo fixtures (#10330) --- apps/web/src/components/usage/UsageLimits.tsx | 8 +- apps/web/src/components/usage/UsagePage.tsx | 39 +- .../components/usage/usageLimitsFixture.ts | 413 ------------------ 3 files changed, 6 insertions(+), 454 deletions(-) delete mode 100644 apps/web/src/components/usage/usageLimitsFixture.ts diff --git a/apps/web/src/components/usage/UsageLimits.tsx b/apps/web/src/components/usage/UsageLimits.tsx index 0f15631acb99..3f7168ad32f8 100644 --- a/apps/web/src/components/usage/UsageLimits.tsx +++ b/apps/web/src/components/usage/UsageLimits.tsx @@ -9,7 +9,6 @@ import { } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; import { - collectLimitAccounts, elapsedShare, formatDuration, formatResetsIn, @@ -36,7 +35,6 @@ import { } from "../ui/alert-dialog"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import type { makeLimitsFixture } from "./usageLimitsFixture"; import { UsageLimitsPooled } from "./UsageLimitsPooled"; import { PROVIDER_PRESENTATION } from "./usageProviders"; @@ -323,16 +321,12 @@ export function ResetCredits({ */ export function UsageLimitsSection({ selectedEnvironmentIds, - fixture = null, }: { readonly selectedEnvironmentIds: ReadonlySet | null; - /** Dev-only synthetic presentations standing in for the live ones. */ - readonly fixture?: ReturnType | null; }) { - const live = useAtomValue(environmentPresentations.presentationsAtom); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); // Anchored once per mount on purpose: countdowns must not tick (see above). const [now] = useState(() => Date.now()); - const presentations: Parameters[0] = fixture ?? live; const selected = selectedEnvironmentIds === null ? presentations diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 066f8549f5e9..deb05f266b98 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -59,7 +59,6 @@ import { import { WorkspacePageContainer } from "../WorkspacePageContainer"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { UsageLimitsSection } from "./UsageLimits"; -import { makeLimitsFixture } from "./usageLimitsFixture"; import { UsagePriceOverrides } from "./UsagePriceOverrides"; import { UsageProviderChart, type UsageChartMetric } from "./UsageProviderChart"; import { PROVIDER_ORDER, PROVIDER_PRESENTATION, providersWithUsage } from "./usageProviders"; @@ -110,33 +109,10 @@ export function UsagePage() { useState | null>(null); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const usage = useUsage(window, selectedEnvironmentIds); - // Dev only: `/usage?limitsFixture=` lists synthetic environments in the - // picker and feeds the Limits view from them, so merge rules can be eyeballed. - const [fixture] = useState(() => { - if (!import.meta.env.DEV) return null; - const name = new URLSearchParams(globalThis.location.search).get("limitsFixture"); - return name ? makeLimitsFixture(name, Date.now()) : null; - }); - const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = - useMemo(() => { - if (!fixture || !showingLimits) return usage; - const all = [...fixture].map(([environmentId, presentation]) => ({ - environmentId, - label: presentation.entry.target.label, - isPending: false, - error: null, - summary: null, - })); - return { - ...usage, - environments: all, - selectedEnvironments: - selectedEnvironmentIds === null - ? all - : all.filter((environment) => selectedEnvironmentIds.has(environment.environmentId)), - }; - }, [fixture, selectedEnvironmentIds, showingLimits, usage]); + const { merged, environments, selectedEnvironments, isPending, isPartial, refresh } = useUsage( + window, + selectedEnvironmentIds, + ); const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, @@ -190,8 +166,6 @@ export function UsagePage() { if (refreshingRef.current) return; if (showingLimits) { - // Synthetic data has nothing to re-read. - if (fixture) return; refreshingRef.current = true; setIsRefreshing(true); void Promise.all( @@ -375,10 +349,7 @@ export function UsagePage() { : `Select an environment to see ${showingLimits ? "limits" : "usage"}.`}

) : showingLimits ? ( - + ) : isPending ? ( ) : ( diff --git a/apps/web/src/components/usage/usageLimitsFixture.ts b/apps/web/src/components/usage/usageLimitsFixture.ts deleted file mode 100644 index 8958707b98de..000000000000 --- a/apps/web/src/components/usage/usageLimitsFixture.ts +++ /dev/null @@ -1,413 +0,0 @@ -/** - * Dev-only stand-ins for `environmentPresentations` that exercise the pooled - * Limits view's merge rules, one scenario per named fixture. Reached with - * `/usage?limitsFixture=` on a dev build; never bundled otherwise. - */ -import { - EnvironmentId, - ProviderDriverKind, - ProviderInstanceId, - type ServerProvider, - type ServerProviderUsageWindow, - type UsageLimitSourceSnapshot, - UsageLimitSourceId, -} from "@t3tools/contracts"; - -const MINUTE = 60_000; -const HOUR = 60 * MINUTE; -const DAY = 24 * HOUR; - -interface Presentation { - readonly entry: { readonly target: { readonly label: string } }; - readonly serverConfig: { - readonly providers: readonly ServerProvider[]; - readonly usageLimitSources: readonly UsageLimitSourceSnapshot[]; - }; -} - -type Fixture = ReadonlyMap; - -const codex = ProviderDriverKind.make("codex"); -const claude = ProviderDriverKind.make("claudeAgent"); - -function makeHelpers(now: number) { - const at = (ms: number) => new Date(now + ms).toISOString(); - const checked = (agoMs: number) => new Date(now - agoMs).toISOString(); - - const session = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ - id: "five_hour", - kind: "session", - label: "Session", - usedPercent: used, - windowDurationMins: 300, - resetsAt: at(resetsInMs), - }); - const weekly = ( - id: string, - label: string, - used: number, - resetsInMs: number, - ): ServerProviderUsageWindow => ({ - id, - kind: "weekly", - label, - usedPercent: used, - windowDurationMins: 7 * 24 * 60, - resetsAt: at(resetsInMs), - }); - /** Codex names its five-hour window `primary` and its weekly one `secondary`. */ - const codexSession = (used: number, resetsInMs: number): ServerProviderUsageWindow => ({ - ...session(used, resetsInMs), - id: "primary", - }); - const codexWeekly = (used: number, resetsInMs: number) => - weekly("secondary", "Weekly", used, resetsInMs); - - const provider = ( - overrides: Partial & Pick, - ): ServerProvider => ({ - enabled: true, - installed: true, - version: null, - status: "ready", - auth: { status: "authenticated" }, - checkedAt: checked(0), - models: [], - slashCommands: [], - skills: [], - ...overrides, - }); - - const codexInstance = (input: { - readonly instanceId: string; - readonly displayName?: string; - readonly accentColor?: string; - readonly email: string; - readonly plan?: string; - readonly checkedAgoMs?: number; - readonly windows: readonly ServerProviderUsageWindow[]; - readonly credits?: number; - }) => - provider({ - instanceId: ProviderInstanceId.make(input.instanceId), - driver: codex, - ...(input.displayName ? { displayName: input.displayName } : {}), - ...(input.accentColor ? { accentColor: input.accentColor } : {}), - auth: { - status: "authenticated", - label: input.plan ?? "ChatGPT Pro 20x Subscription", - email: input.email, - }, - usageLimits: { - checkedAt: checked(input.checkedAgoMs ?? MINUTE), - windows: input.windows, - ...(input.credits - ? { resetCredits: { availableCount: input.credits, nextExpiresAt: at(28 * DAY) } } - : {}), - }, - }); - - const claudeHubAccount = ( - email: string | null, - windows: readonly ServerProviderUsageWindow[], - checkedAgoMs = 4 * MINUTE, - ): UsageLimitSourceSnapshot["accounts"][number] => ({ - id: email ? `claude-${email}.json` : "claude-team-seat.json", - driver: claude, - ...(email ? { email } : {}), - plan: "Claude Subscription", - usageLimits: { checkedAt: checked(checkedAgoMs), windows }, - }); - - const hub = ( - id: string, - label: string, - accounts: UsageLimitSourceSnapshot["accounts"], - error?: string, - ): UsageLimitSourceSnapshot => ({ - id: UsageLimitSourceId.make(id), - kind: "cliproxy", - label, - checkedAt: checked(2 * MINUTE), - accounts, - ...(error ? { error } : {}), - }); - - const environment = ( - id: string, - label: string, - providers: readonly ServerProvider[], - usageLimitSources: readonly UsageLimitSourceSnapshot[] = [], - ): readonly [EnvironmentId, Presentation] => [ - EnvironmentId.make(id), - { entry: { target: { label } }, serverConfig: { providers, usageLimitSources } }, - ]; - - return { - at, - checked, - session, - weekly, - codexSession, - codexWeekly, - provider, - codexInstance, - claudeHubAccount, - hub, - environment, - }; -} - -const FIXTURES: Record Fixture> = { - /** - * The same Codex account signed in on two machines with different snapshot - * ages, plus a hub that also reports it. Must collapse to one segment with - * the freshest figures and both machines listed. - */ - "same-account": (now) => { - const h = makeHelpers(now); - const email = "main@example.com"; - const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ - { - id: `codex-abc-${email}-pro.json`, - driver: codex, - email, - plan: "ChatGPT Pro 20x Subscription", - usageLimits: { checkedAt: h.checked(14 * MINUTE), windows: [h.codexWeekly(70, 5 * DAY)] }, - }, - ]; - return new Map([ - h.environment( - "env-macbook", - "MacBook Pro", - [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Personal", - accentColor: "#6366f1", - email, - windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], - credits: 2, - }), - ], - [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], - ), - h.environment("env-nucbox", "nucbox-1", [ - h.codexInstance({ - instanceId: "codex", - email, - checkedAgoMs: 9 * MINUTE, - windows: [h.codexSession(30, 3 * HOUR), h.codexWeekly(60, 5 * DAY)], - credits: 2, - }), - ]), - ]); - }, - - /** - * Three machines, no two alike: one has only Codex, one only Claude via a - * hub, one has both natively. Filtering to any single environment should - * drop whole provider sections. - */ - "uneven-environments": (now) => { - const h = makeHelpers(now); - return new Map([ - h.environment("env-macbook", "MacBook Pro", [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Personal", - accentColor: "#6366f1", - email: "main@example.com", - windows: [h.codexSession(10, 3 * HOUR), h.codexWeekly(66, 5 * DAY)], - credits: 2, - }), - h.provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: claude, - auth: { status: "authenticated", label: "Claude Max", email: "main@example.com" }, - usageLimits: { - checkedAt: h.checked(MINUTE), - windows: [ - h.session(2, 4 * HOUR), - h.weekly("seven_day", "Weekly", 38, 4 * DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), - ], - }, - }), - ]), - h.environment( - "env-nucbox", - "nucbox-1", - [], - [ - h.hub("cliproxy-nucbox", "CLI Proxy", [ - h.claudeHubAccount("personal@example.com", [ - h.session(100, 4 * HOUR), - h.weekly("seven_day", "Weekly", 50, DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 96, DAY), - ]), - h.claudeHubAccount("second@example.org", [ - h.session(63, 2 * HOUR), - h.weekly("seven_day", "Weekly", 37, 4 * DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 72, 4 * DAY), - ]), - ]), - ], - ), - h.environment("env-macmini", "Mac Mini", [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Work", - email: "work@example.com", - windows: [h.codexSession(0, 5 * HOUR), h.codexWeekly(95, 5 * DAY)], - credits: 1, - }), - ]), - ]); - }, - - /** - * Codex plans that report only one window (Go reports a monthly allowance; - * a hub often has no five-hour figure for an account) mixed with a plan that - * reports both. Each pool lists only the accounts that have that window. - */ - "codex-window-mix": (now) => { - const h = makeHelpers(now); - return new Map([ - h.environment( - "env-macbook", - "MacBook Pro", - [ - h.codexInstance({ - instanceId: "codex", - displayName: "Codex Personal", - accentColor: "#6366f1", - email: "main@example.com", - windows: [h.codexSession(40, 2 * HOUR), h.codexWeekly(55, 3 * DAY)], - credits: 2, - }), - h.codexInstance({ - instanceId: "codex-go", - displayName: "Codex Go", - accentColor: "#10b981", - email: "go@example.com", - plan: "ChatGPT Go Subscription", - windows: [ - { - id: "primary", - kind: "monthly", - label: "Monthly", - usedPercent: 82, - windowDurationMins: 30 * 24 * 60, - resetsAt: h.at(11 * DAY), - }, - ], - }), - ], - [ - h.hub("cliproxy-nucbox", "CLI Proxy", [ - { - id: "codex-def-work@example.com-pro.json", - driver: codex, - email: "work@example.com", - plan: "ChatGPT Pro 20x Subscription", - usageLimits: { - checkedAt: h.checked(3 * MINUTE), - windows: [h.codexWeekly(88, 6 * DAY)], - }, - }, - { - id: "codex-ghi-team@example.net-plus.json", - driver: codex, - email: "team@example.net", - plan: "ChatGPT Plus Subscription", - usageLimits: { - checkedAt: h.checked(3 * MINUTE), - windows: [h.codexWeekly(12, DAY)], - }, - }, - ]), - ], - ), - ]); - }, - - /** - * A hub configured on two environments, a hub that is down, a provider - * whose probe failed, an API-key account, and a hub account with no email. - */ - "failures-and-strays": (now) => { - const h = makeHelpers(now); - const hubAccounts: UsageLimitSourceSnapshot["accounts"] = [ - h.claudeHubAccount("main@example.com", [ - h.session(2, 4 * HOUR), - h.weekly("seven_day", "Weekly", 38, 4 * DAY), - h.weekly("seven_day_fable", "Weekly · Fable", 69, 4 * DAY), - ]), - h.claudeHubAccount(null, [ - h.session(40, 2 * HOUR), - h.weekly("seven_day", "Weekly", 20, 6 * DAY), - ]), - ]; - return new Map([ - h.environment( - "env-macbook", - "MacBook Pro", - [ - h.provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: claude, - auth: { status: "authenticated", label: "Claude API Key" }, - usageLimits: { - checkedAt: h.checked(MINUTE), - windows: [], - unavailable: { - reason: "unsupported", - message: "This account has no subscription limits.", - }, - }, - }), - ], - [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], - ), - h.environment( - "env-nucbox", - "nucbox-1", - [], - [h.hub("cliproxy-nucbox", "CLI Proxy", hubAccounts)], - ), - h.environment( - "env-macmini", - "Mac Mini", - [ - h.provider({ - instanceId: ProviderInstanceId.make("claude"), - driver: claude, - auth: { - status: "authenticated", - label: "Claude Max", - email: "work@example.com", - }, - usageLimits: { - checkedAt: h.checked(MINUTE), - windows: [], - unavailable: { reason: "probeFailed", message: "Claude timed out reading usage." }, - }, - }), - ], - [ - h.hub( - "cliproxy-aws", - "AWS proxy", - [], - "fetch failed: connect ECONNREFUSED 10.0.0.4:8318", - ), - ], - ), - ]); - }, -}; - -export function makeLimitsFixture(name: string, now: number): Fixture | null { - return Object.hasOwn(FIXTURES, name) ? FIXTURES[name]!(now) : null; -} From 127efae4401cf2cc0fcd7ec7c5b2e2c037fe4151 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:11:41 +1000 Subject: [PATCH 198/320] fix(web): expose error disclosure state (#10125) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/src/components/settings/ExpandableText.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/settings/ExpandableText.tsx b/apps/web/src/components/settings/ExpandableText.tsx index de18739e5090..fa5fb94aadd1 100644 --- a/apps/web/src/components/settings/ExpandableText.tsx +++ b/apps/web/src/components/settings/ExpandableText.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { cn } from "../../lib/utils"; @@ -17,12 +17,14 @@ export function ExpandableText({ collapsedClassName?: string; expandLabel?: string; }) { + const textId = useId(); const [expanded, setExpanded] = useState(false); const canExpand = text.length > 180 || text.includes("\n"); return (
setExpanded((value) => !value)} > From bc028738aabf20350ca3c2aa17ecfd41a1f74721 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:19:53 +1000 Subject: [PATCH 199/320] fix(web): name the editor picker accurately (#10124) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/src/components/chat/OpenInPicker.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/apps/web/src/components/chat/OpenInPicker.tsx b/apps/web/src/components/chat/OpenInPicker.tsx index b9bf831c14d9..9f8e81bdba67 100644 --- a/apps/web/src/components/chat/OpenInPicker.tsx +++ b/apps/web/src/components/chat/OpenInPicker.tsx @@ -294,13 +294,7 @@ export const OpenInPicker = memo(function OpenInPicker({ - } + render={
- Connect an environment to get started + Connect to a computer running T3 Code + + This browser connects to T3 Code running on your computer or a server. Start the T3 + Code desktop app or command-line server on that machine and keep it running. + {cloudEnabled - ? "Sign in to T3 Connect to connect a linked environment through its managed tunnel, or add a reachable backend manually." - : "Add a reachable backend manually to start working from this browser."} + ? "Enable T3 Connect on that machine, then open Connections here to sign in with the same account. You can also add the machine using a pairing link." + : "Open Connections and add that machine using its pairing link. This browser must be able to reach it."}
From 55333833ec260a5c9eefa05690086de768d6970c Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:22:21 +1000 Subject: [PATCH 202/320] fix(web): name combobox chip removal targets (#10127) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/web/src/components/ui/combobox.tsx | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/ui/combobox.tsx b/apps/web/src/components/ui/combobox.tsx index e2253100d2d8..d9f09690331d 100644 --- a/apps/web/src/components/ui/combobox.tsx +++ b/apps/web/src/components/ui/combobox.tsx @@ -352,27 +352,37 @@ function ComboboxChips({ } function ComboboxChip({ children, ...props }: ComboboxPrimitive.Chip.Props) { + const labelId = React.useId(); + return ( - {children} - + {children} + ); } -function ComboboxChipRemove(props: ComboboxPrimitive.ChipRemove.Props) { +function ComboboxChipRemove({ + labelId, + ...props +}: ComboboxPrimitive.ChipRemove.Props & { labelId: string }) { + const removeLabelId = `${labelId}-remove`; + return ( - + + Remove + + ); } From e5d086c262daab13a8adbb253e281c07ab235533 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Sun, 6 Sep 2026 17:23:15 +1000 Subject: [PATCH 203/320] fix(marketing): present the Git workflow as an illustration (#10130) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/marketing/src/pages/index.astro | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index 723ad4d4324b..db31f24e6bec 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -205,7 +205,11 @@ const screenshot = await getImage({
-
+ @@ -902,6 +906,11 @@ const screenshot = await getImage({ align-items: stretch; gap: 20px; } + .git-visual .btn { + line-height: normal; + pointer-events: none; + } + .pr-card { padding: 20px; } .pr-head { display: flex; align-items: center; gap: 10px; @@ -955,6 +964,7 @@ const screenshot = await getImage({ padding: 12px 20px; background: var(--fg); color: #09090b; font-weight: 600; font-size: 13px; + line-height: normal; border-radius: 10px; box-shadow: 0 8px 24px -8px rgba(255, 255, 255, 0.2); } From b155c2199bdcb4fdf1920da02a223536f8c2d729 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 00:57:43 -0700 Subject: [PATCH 204/320] feat(mobile): pool usage limits across selected environments (#10334) --- apps/mobile/src/Stack.tsx | 5 + apps/mobile/src/components/AppSymbol.tsx | 3 + apps/mobile/src/components/ControlPill.tsx | 18 +- .../src/features/usage/UsageLimitsPooled.tsx | 374 ++++++++++++++++++ .../src/features/usage/UsageLimitsSection.tsx | 157 +------- .../src/features/usage/UsageRouteScreen.tsx | 317 ++++++++++----- .../usage/usageEnvironmentSelection.test.ts | 40 ++ .../usage/usageEnvironmentSelection.ts | 16 + apps/mobile/src/state/usage.ts | 32 +- docs/user/usage.md | 8 +- packages/shared/src/usageLimits.ts | 22 +- 11 files changed, 716 insertions(+), 276 deletions(-) create mode 100644 apps/mobile/src/features/usage/UsageLimitsPooled.tsx create mode 100644 apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts create mode 100644 apps/mobile/src/features/usage/usageEnvironmentSelection.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 57303a1bb001..dd0b48700ef7 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { UsageLimitAccountScreen } from "./features/usage/UsageLimitsPooled"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; @@ -192,6 +193,10 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsUsageAccount: createNativeStackScreen({ + screen: UsageLimitAccountScreen, + options: { title: "Account" }, + }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "usage", diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 8667ebb72475..0912693861de 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -31,6 +31,7 @@ import IconChevronRight from "@tabler/icons-react-native/IconChevronRight"; import IconChevronUp from "@tabler/icons-react-native/IconChevronUp"; import IconCircleCheck from "@tabler/icons-react-native/IconCircleCheck"; import IconCircleXFilled from "@tabler/icons-react-native/IconCircleXFilled"; +import IconTicket from "@tabler/icons-react-native/IconTicket"; import IconClock from "@tabler/icons-react-native/IconClock"; import IconCode from "@tabler/icons-react-native/IconCode"; import IconCopy from "@tabler/icons-react-native/IconCopy"; @@ -116,6 +117,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + ticket: IconTicket, cloud: IconCloud, cube: IconBox, "chevron.down": IconChevronDown, @@ -138,6 +140,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "info.circle": IconInfoCircle, laptopcomputer: IconDeviceLaptop, link: IconLink, + "line.3.horizontal.decrease": IconFilter, "line.3.horizontal.decrease.circle": IconFilter, "line.3.horizontal.decrease.circle.fill": IconFilterFilled, // Tabler has no Apple desktops; the closest silhouettes stand in on Android. diff --git a/apps/mobile/src/components/ControlPill.tsx b/apps/mobile/src/components/ControlPill.tsx index b7412bd13a83..e0936c57180a 100644 --- a/apps/mobile/src/components/ControlPill.tsx +++ b/apps/mobile/src/components/ControlPill.tsx @@ -9,7 +9,14 @@ import { useMemo, useRef, } from "react"; -import { Platform, Pressable, View, type ColorValue, type PressableProps } from "react-native"; +import { + Platform, + Pressable, + View, + type ColorValue, + type PressableProps, + type AccessibilityProps, +} from "react-native"; import { withUniwind } from "uniwind"; import { useAppearancePreferences } from "../features/settings/appearance/AppearancePreferencesProvider"; @@ -144,10 +151,11 @@ export function ControlPill(props: { // AppCompat popup can't be themed past its stock animation, metrics, and // submenu chrome. export function ControlPillMenu( - props: Omit, "children" | "themeVariant"> & { - readonly children: ReactNode; - readonly className?: string; - }, + props: Omit, "children" | "themeVariant"> & + Pick & { + readonly children: ReactNode; + readonly className?: string; + }, ) { const { themeAppearance } = useAppearancePreferences(); const isDarkMode = themeAppearance === "dark"; diff --git a/apps/mobile/src/features/usage/UsageLimitsPooled.tsx b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx new file mode 100644 index 000000000000..61828ad90ee0 --- /dev/null +++ b/apps/mobile/src/features/usage/UsageLimitsPooled.tsx @@ -0,0 +1,374 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigation, type StaticScreenProps } from "@react-navigation/native"; +import { EnvironmentId } from "@t3tools/contracts"; +import { + collectLimitAccounts, + collectLimitNotices, + collectLimitPools, + formatDuration, + formatResetsIn, + remainingPercent, + type LimitAccount, + type LimitPoolWindow, +} from "@t3tools/shared/usageLimits"; +import { useId, useState } from "react"; +import { Platform, Pressable, ScrollView, View } from "react-native"; +import { Defs, Path, Pattern, Rect, Svg } from "react-native-svg"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { SymbolView } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { ProviderIcon } from "../../components/ProviderIcon"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { environmentPresentations } from "../../state/presentation"; +import { ResetCredits } from "./UsageLimitsSection"; +import { useProviderColors } from "./usageProviders"; + +const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; +const PACE_LABEL = { ahead: "Ahead of pace", on: "On pace", under: "Under pace" } as const; + +function accountName(account: LimitAccount) { + if (account.displayName) return account.displayName; + if (!account.email) return DRIVER_LABEL[account.driver] ?? String(account.driver); + const [local = "", domain = ""] = account.email.split("@"); + return `${local[0] ?? ""}${domain[0] ?? ""}`.toUpperCase() || "Account"; +} + +/** The spent share comes back at reset. SVG keeps the hatching static on both platforms. */ +function AccountSegment({ + remaining, + color, + pending, +}: { + readonly remaining: number; + readonly color: string; + readonly pending: boolean; +}) { + const patternId = useId().replace(/:/g, ""); + return ( + + + + + + + {pending ? ( + + ) : null} + + + ); +} + +function PoolWindowCard({ + pool, + color, + now, + environmentIds, +}: { + readonly pool: LimitPoolWindow; + readonly color: string; + readonly now: number; + readonly environmentIds: readonly string[] | null; +}) { + const navigation = useNavigation(); + const nextRefill = pool.resets.find((reset) => reset.restoresPercent > 0); + const openAccount = (account: LimitAccount) => + navigation.navigate("SettingsSheet", { + screen: "SettingsContent", + params: { + screen: "SettingsUsageAccount", + params: { + accountKey: account.key, + windowId: pool.id, + windowKind: pool.kind, + environmentIds, + now, + }, + }, + }); + return ( + + + + {pool.label} + + + {pool.remainingPercent}% + + left + + + {pool.pace ? ( + {PACE_LABEL[pool.pace]} + ) : null} + + {nextRefill ? ( + + ↻ +{nextRefill.restoresPercent}%{" "} + {nextRefill.at <= now ? "now" : `in ${formatDuration(nextRefill.at - now)}`} + + ) : null} + + {pool.members.map(({ account, window }, index) => ( + openAccount(account)} + className="h-7 min-w-0 flex-1 overflow-hidden rounded-md bg-subtle" + > + + + + {index + 1} + + + + ))} + + + {pool.members.map(({ account, window }, index) => { + const credits = account.redeem ? (account.limits.resetCredits?.availableCount ?? 0) : 0; + const resetsIn = formatResetsIn(window, now); + return ( + openAccount(account)} + className="min-h-[44px] flex-row items-center gap-2 active:opacity-60" + > + + + {index + 1} + + + + {accountName(account)} + + + {remainingPercent(window)}% + + + {resetsIn ? ( + + {resetsIn.replace("resets in ", "↻ ")} + + ) : null} + {credits ? ( + <> + {resetsIn ? · : null} + + + {credits} + + + ) : null} + + + ); + })} + + + ); +} + +export function UsageLimitsSection({ + now, + failedLabels, + selectedEnvironmentIds, +}: { + readonly now: number; + readonly failedLabels: readonly string[]; + readonly selectedEnvironmentIds: ReadonlySet | null; +}) { + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const selected = + selectedEnvironmentIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedEnvironmentIds.has(id))); + const pools = collectLimitPools(collectLimitAccounts(selected), now); + const notices = collectLimitNotices(selected); + const colors = useProviderColors(); + return ( + + {failedLabels.length ? ( + + {failedLabels.join(", ")} could not refresh limits. Showing the last known values. + + ) : null} + {pools.length === 0 ? ( + + {selected.size === 0 + ? "Select an environment to see limits." + : "No provider on the selected environments reports subscription limits."} + + ) : null} + {pools.map((pool) => ( + + + + + {DRIVER_LABEL[pool.driver] ?? pool.driver} + + + {pool.windows.map((window) => ( + + ))} + + ))} + {notices.map((notice) => ( + + {notice} + + ))} + + ); +} + +type AccountScreenProps = StaticScreenProps<{ + accountKey: string; + windowId: string; + windowKind: LimitPoolWindow["kind"]; + environmentIds: readonly string[] | null; + now: number; +}>; + +/** Resolve the account again so live quota and credit updates reach the open detail screen. */ +export function UsageLimitAccountScreen({ route }: AccountScreenProps) { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const presentations = useAtomValue(environmentPresentations.presentationsAtom); + const { accountKey, windowId, windowKind, environmentIds, now } = route.params; + const selectedIds = + environmentIds === null ? null : new Set(environmentIds.map((id) => EnvironmentId.make(id))); + const selected = + selectedIds === null + ? presentations + : new Map([...presentations].filter(([id]) => selectedIds.has(id))); + const accounts = collectLimitAccounts(selected); + const account = accounts.find((candidate) => candidate.key === accountKey); + const pool = collectLimitPools(accounts, now) + .find((candidate) => candidate.driver === account?.driver) + ?.windows.find((candidate) => candidate.id === windowId && candidate.kind === windowKind); + const window = pool?.members.find((member) => member.account.key === accountKey)?.window; + const reset = pool?.resets.find((candidate) => candidate.member.account.key === accountKey); + const [revealed, setRevealed] = useState(false); + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + {!account || !window ? ( + + This account is no longer reporting limits on the selected environments. + + ) : ( + <> + + + + + {account.displayName ?? DRIVER_LABEL[account.driver] ?? account.driver} + + + {account.email ? ( + setRevealed((value) => !value)} + className="min-h-[44px] justify-center" + > + + {revealed ? account.email : "••••••@••••••"} + + + ) : null} + {account.plan ? ( + + {account.plan} + + ) : null} + + + {window.label} + + {remainingPercent(window)}% left + + {window.resetsAt ? ( + + Resets{" "} + {new Date(window.resetsAt).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} + + ) : null} + {reset && reset.restoresPercent > 0 ? ( + + Restores {reset.restoresPercent}% of the pool + + ) : null} + + + + {account.environments.length ? "Signed in" : "Source"} + + {account.environments.length ? ( + account.environments.map((environment) => ( + + {environment.label} + + )) + ) : ( + {account.sourceLabel} + )} + + {account.redeem && account.limits.resetCredits ? ( + + Reset credits + + + ) : null} + + )} + + + ); +} diff --git a/apps/mobile/src/features/usage/UsageLimitsSection.tsx b/apps/mobile/src/features/usage/UsageLimitsSection.tsx index 460827b22649..a923e903ef18 100644 --- a/apps/mobile/src/features/usage/UsageLimitsSection.tsx +++ b/apps/mobile/src/features/usage/UsageLimitsSection.tsx @@ -6,18 +6,14 @@ import type { ServerProvider, ServerProviderResetCredits, ServerProviderUsageWindow, - UsageLimitSourceAccount, UsageProviderKind, } from "@t3tools/contracts"; import { - collectLimitSources, - collectLimitsGroups, elapsedShare, formatDuration, formatResetsIn, limitsNotice, paceOf, - providerLimitsLabel, remainingPercent, } from "@t3tools/shared/usageLimits"; import { type ReactNode, useState } from "react"; @@ -28,11 +24,9 @@ import { ProviderIcon } from "../../components/ProviderIcon"; import { environmentPresentations } from "../../state/presentation"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; -import { SettingsSection } from "../settings/components/SettingsSection"; import { useProviderColors } from "./usageProviders"; const PACE_LABEL = { ahead: "ahead of pace", on: "on pace", under: "under pace" } as const; -const DRIVER_LABEL: Partial> = { codex: "Codex", claudeAgent: "Claude" }; type Driver = ServerProvider["driver"]; @@ -186,7 +180,7 @@ export function ResetCredits(props: { }); const [busy, setBusy] = useState(false); const [status, setStatus] = useState(null); - if (credits.availableCount === 0 && status === null) return null; + if (dense && credits.availableCount === 0 && status === null) return null; const expiresIn = credits.nextExpiresAt ? formatDuration(Date.parse(credits.nextExpiresAt) - now) @@ -237,7 +231,7 @@ export function ResetCredits(props: { className={ dense ? "rounded-full bg-subtle-strong px-2.5 py-1" - : "rounded-full bg-subtle-strong px-3 py-1.5" + : "min-h-[44px] justify-center rounded-full bg-subtle-strong px-3 py-1.5" } > DRIVER_LABEL[driver])} - detail={provider.auth.label} - limits={provider.usageLimits} - now={now} - first={props.first} - footer={ - credits ? ( - - ) : undefined - } - /> - ); -} - -/** Emails stay off the phone screen; the plan and driver identify the row. */ -function SourceAccountLimits(props: { - readonly account: UsageLimitSourceAccount; - readonly now: number; - readonly first: boolean; -}) { - const { account } = props; - return ( - - ); -} - /** * Re-probes every provider (and usage-limit source) on each connected * environment; the fresh snapshots then arrive over the config stream. @@ -315,107 +258,47 @@ function SourceAccountLimits(props: { * Environments whose probe failed are named, since their rows keep showing * the previous quota with nothing else to say so. */ -export function useRefreshLimits() { +export function useRefreshLimits(selectedEnvironmentIds: ReadonlySet | null = null) { const presentations = useAtomValue(environmentPresentations.presentationsAtom); const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { reportFailure: false, }); const [now, setNow] = useState(() => Date.now()); const [refreshing, setRefreshing] = useState(false); - const [failedLabels, setFailedLabels] = useState([]); + const [failedEnvironments, setFailedEnvironments] = useState< + readonly { environmentId: EnvironmentId; label: string }[] + >([]); // Always toggles `refreshing`, even with nothing to probe: Android's // RefreshControl keeps its spinner up until it sees true then false. const refresh = async () => { const connected = [...presentations].filter( - ([, presentation]) => presentation.connection.phase === "connected", + ([environmentId, presentation]) => + presentation.connection.phase === "connected" && + (selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId)), ); setRefreshing(true); try { const results = await Promise.all( connected.map(([environmentId]) => refreshProviders({ environmentId, input: {} })), ); - setFailedLabels( + setFailedEnvironments( connected .filter((_, index) => results[index]?._tag === "Failure") - .map(([, presentation]) => presentation.entry.target.label), + .map(([environmentId, presentation]) => ({ + environmentId, + label: presentation.entry.target.label, + })), ); } finally { setNow(Date.now()); setRefreshing(false); } }; + const failedLabels = failedEnvironments + .filter( + ({ environmentId }) => + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environmentId), + ) + .map(({ label }) => label); return { now, refreshing, failedLabels, refresh }; } - -/** - * Subscription quota windows from every connected environment's providers, - * read from the config each environment already streams. - */ -export function UsageLimitsSection(props: { - readonly now: number; - readonly failedLabels: readonly string[]; -}) { - const { now } = props; - const presentations = useAtomValue(environmentPresentations.presentationsAtom); - const groups = collectLimitsGroups(presentations); - const sources = collectLimitSources(presentations); - - if (groups.length === 0 && sources.length === 0) { - return ( - - No provider on a connected environment reports subscription limits. - - ); - } - - return ( - <> - {props.failedLabels.length > 0 ? ( - - - {props.failedLabels.join(", ")} could not refresh limits. Showing the last known values. - - - ) : null} - {groups.map((group) => ( - - {group.providers.map((provider, index) => ( - - ))} - - ))} - {sources.map((source) => ( - - {source.error ? ( - {source.error} - ) : source.accounts.length === 0 ? ( - - {source.hiddenAccountCount > 0 - ? "All accounts are shown by connected providers." - : "No accounts reported."} - - ) : ( - source.accounts.map((account, index) => ( - - )) - )} - - ))} - - ); -} diff --git a/apps/mobile/src/features/usage/UsageRouteScreen.tsx b/apps/mobile/src/features/usage/UsageRouteScreen.tsx index 164b09556511..3e5cd0fc9e3d 100644 --- a/apps/mobile/src/features/usage/UsageRouteScreen.tsx +++ b/apps/mobile/src/features/usage/UsageRouteScreen.tsx @@ -1,5 +1,10 @@ +import { EnvironmentId, USAGE_CONTRACT_VERSION } from "@t3tools/contracts"; import { useNavigation } from "@react-navigation/native"; -import type { DailyTotals, MergedUsage } from "@t3tools/shared/usageMerge"; +import { + isCompatibleUsageContractVersion, + type DailyTotals, + type MergedUsage, +} from "@t3tools/shared/usageMerge"; import { enumerateDays, enumerateHourStarts, @@ -11,8 +16,9 @@ import { formatUsd, makeWindow, } from "@t3tools/shared/usageFormat"; -import { useMemo, useRef, useState } from "react"; +import { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import { Platform, Pressable, RefreshControl, ScrollView, View } from "react-native"; +import Animated, { Easing, FadeIn, LinearTransition, ReduceMotion } from "react-native-reanimated"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; @@ -22,7 +28,11 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useUsage, type EnvironmentUsageStatus } from "../../state/usage"; import { SettingsSection } from "../settings/components/SettingsSection"; import { UsageDailyChart } from "./UsageDailyChart"; -import { UsageLimitsSection, useRefreshLimits } from "./UsageLimitsSection"; +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; +import { useRefreshLimits } from "./UsageLimitsSection"; +import { UsageLimitsSection } from "./UsageLimitsPooled"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { SymbolView } from "../../components/AppSymbol"; import type { UsageChartMetric } from "./usageChartData"; import { PROVIDER_LABEL, useProviderColors } from "./usageProviders"; @@ -64,8 +74,13 @@ export function UsageRouteScreen() { const [metric, setMetric] = useState("cost"); const { days: windowDays, window } = windowSelection; const isPast24Hours = windowDays === 1; - const { merged, environments, isPending, isPartial, refresh } = useUsage(window); - const limits = useRefreshLimits(); + const [selectedEnvironmentIds, setSelectedEnvironmentIds] = + useState | null>(null); + const { merged, environments, selectedEnvironments, isPending, refresh } = useUsage( + window, + selectedEnvironmentIds, + ); + const limits = useRefreshLimits(selectedEnvironmentIds); const days = useMemo( () => enumerateDays(window.sinceDay, window.untilDay), @@ -119,18 +134,104 @@ export function UsageRouteScreen() { }); }; + const showEnvironmentFilter = environments.length > 0 || selectedEnvironmentIds !== null; + const hasLoadingEnvironments = selectedEnvironments.some(isUsageLoading); + const filterAccessibilityLabel = hasLoadingEnvironments + ? "Filter usage environments, some environments are loading" + : "Filter usage environments"; + const filterIcon = + selectedEnvironmentIds === null + ? "line.3.horizontal.decrease" + : "line.3.horizontal.decrease.circle.fill"; + const environmentActions = useMemo( + () => [ + { + id: "all", + title: "All environments", + subtitle: undefined, + state: selectedEnvironmentIds === null ? ("on" as const) : ("off" as const), + }, + ...environments.map((environment) => ({ + id: environment.environmentId, + title: environment.label, + subtitle: usageEnvironmentStatus(environment), + state: + selectedEnvironmentIds === null || selectedEnvironmentIds.has(environment.environmentId) + ? ("on" as const) + : ("off" as const), + })), + ], + [environments, selectedEnvironmentIds], + ); + const selectEnvironment = useCallback( + (value: string) => { + if (value === "all") { + setSelectedEnvironmentIds(null); + return; + } + const id = EnvironmentId.make(value); + setSelectedEnvironmentIds((selected) => toggleUsageEnvironment(selected, environments, id)); + }, + [environments], + ); + const environmentFilter = useMemo( + () => + showEnvironmentFilter ? ( + selectEnvironment(nativeEvent.event)} + > + + + {hasLoadingEnvironments ? ( + + ) : null} + + + ) : null, + [ + showEnvironmentFilter, + environmentActions, + selectEnvironment, + filterAccessibilityLabel, + filterIcon, + hasLoadingEnvironments, + ], + ); + + useLayoutEffect(() => { + if (Platform.OS === "ios") { + navigation.setOptions({ headerRight: () => environmentFilter }); + } + }, [navigation, environmentFilter]); + return ( {Platform.OS === "android" ? ( <> - navigation.goBack()} /> + navigation.goBack()} + trailing={environmentFilter} + /> ) : null} - {showingLimits ? ( - - ) : ( - <> - {/* Period and metric together: neither applies to Limits, and - both change every number below, so they share one bar. */} - - - - - + {showingLimits ? ( + - {isPending ? ( - - Scanning provider transcripts… - - ) : environments.length === 0 ? ( - - Connect an environment to see usage. - - ) : ( - <> - + {/* Period and metric together: neither applies to Limits, and + both change every number below, so they share one bar. */} + + - - - - - )} - - )} + + + {merged.duplicateSources.length > 0 ? ( + + Counted once across environments sharing a transcript directory:{" "} + {merged.duplicateSources.join(", ")} + + ) : null} + {isPending ? ( + + Scanning provider transcripts… + + ) : selectedEnvironments.length === 0 ? ( + + {environments.length === 0 + ? "Connect an environment to see usage." + : "Select an environment to see usage."} + + ) : ( + <> + + + + + + )} + + )} + ); @@ -221,25 +335,42 @@ function SegmentedControl(props: { const compact = props.size === "compact"; return ( + option.value === props.selected), + ) * + 100) / + props.options.length + }%`, + }} + /> {props.options.map((option) => { const active = option.value === props.selected; return ( props.onSelect(option.value)} className={cn( "flex-1 items-center justify-center rounded-full", compact ? "h-9" : "h-11", - active && "bg-subtle-strong", )} > environment.error !== null); - const stale = props.environments.filter((environment) => - props.merged.staleEnvironments.includes(environment.environmentId), - ); - const duplicateSources = props.merged.duplicateSources; +function isUsageLoading(environment: EnvironmentUsageStatus) { + return environment.isPending || (environment.summary === null && environment.error === null); +} + +function usageEnvironmentStatus(environment: EnvironmentUsageStatus): string { if ( - failed.length === 0 && - stale.length === 0 && - duplicateSources.length === 0 && - !props.isPartial + environment.summary && + !isCompatibleUsageContractVersion(environment.summary.contractVersion, USAGE_CONTRACT_VERSION) ) { - return null; + return "Older server · excluded from usage totals"; } - - return ( - - {props.isPartial ? ( - - Some environments are still reporting. Totals are partial. - - ) : null} - {failed.map((environment) => ( - - {environment.label} could not report usage. - - ))} - {stale.map((environment) => ( - - {environment.label} runs an older server version and is excluded from totals. - - ))} - {duplicateSources.length > 0 ? ( - - Counted once across environments sharing a transcript directory:{" "} - {duplicateSources.join(", ")} - - ) : null} - - ); + if (!environment.isConnected) + return environment.summary ? "Disconnected · showing saved usage" : "Waiting for connection…"; + if (environment.error) + return environment.summary ? "Usage unavailable · showing saved totals" : "Usage unavailable"; + if (isUsageLoading(environment)) + return environment.summary ? "Updating usage…" : "Loading usage…"; + return "Usage up to date"; } diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts new file mode 100644 index 000000000000..4914f9de0f8b --- /dev/null +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.test.ts @@ -0,0 +1,40 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { toggleUsageEnvironment } from "./usageEnvironmentSelection"; + +const a = EnvironmentId.make("a"); +const b = EnvironmentId.make("b"); +const c = EnvironmentId.make("c"); +const removed = EnvironmentId.make("removed"); +const environments = [a, b, c].map((environmentId) => ({ environmentId })); + +describe("usage environment selection", () => { + it("can exclude an environment from all, then select all again", () => { + const selected = toggleUsageEnvironment(null, environments, b); + expect(selected).toEqual(new Set([a, c])); + expect(toggleUsageEnvironment(selected, environments, b)).toBeNull(); + }); + + it("can deselect the last environment", () => { + expect(toggleUsageEnvironment(new Set([a]), environments, a)).toEqual(new Set()); + }); + + it("does not count removed IDs toward selecting all current environments", () => { + expect(toggleUsageEnvironment(new Set([a, removed]), environments, b)).toEqual(new Set([a, b])); + }); + + it("returns to all mode despite stale IDs when every current environment is selected", () => { + expect(toggleUsageEnvironment(new Set([a, c, removed]), environments, b)).toBeNull(); + }); + + it("ignores a menu action for an environment that was removed", () => { + expect(toggleUsageEnvironment(new Set([a]), environments, removed)).toEqual(new Set([a])); + }); + + it("includes newly connected environments only in all mode", () => { + const expanded = [...environments, { environmentId: removed }]; + expect(toggleUsageEnvironment(null, expanded, a)).toEqual(new Set([b, c, removed])); + expect(toggleUsageEnvironment(new Set([a, b, c]), expanded, a)).toEqual(new Set([b, c])); + }); +}); diff --git a/apps/mobile/src/features/usage/usageEnvironmentSelection.ts b/apps/mobile/src/features/usage/usageEnvironmentSelection.ts new file mode 100644 index 000000000000..3f6e9fb3bae5 --- /dev/null +++ b/apps/mobile/src/features/usage/usageEnvironmentSelection.ts @@ -0,0 +1,16 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** Null follows all environments, including ones connected after the menu opened. */ +export function toggleUsageEnvironment( + selected: ReadonlySet | null, + environments: readonly { readonly environmentId: EnvironmentId }[], + toggledId: EnvironmentId, +): ReadonlySet | null { + const ids = environments.map(({ environmentId }) => environmentId); + const next = new Set(ids.filter((id) => selected === null || selected.has(id))); + if (ids.includes(toggledId)) { + if (next.has(toggledId)) next.delete(toggledId); + else next.add(toggledId); + } + return ids.every((id) => next.has(id)) ? null : next; +} diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index 8686a37e2c9c..d49c26a40a44 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -30,6 +30,7 @@ export interface EnvironmentUsageStatus { readonly environmentId: EnvironmentId; readonly label: string; readonly isPending: boolean; + readonly isConnected: boolean; readonly error: string | null; readonly summary: UsageSummary | null; } @@ -53,6 +54,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => environmentId, label: presentation.entry.target.label, isPending: result.waiting, + isConnected: presentation.connection.phase === "connected", error: result._tag === "Failure" ? "This environment could not report usage." : null, summary: Option.getOrNull(AsyncResult.value(result)), }); @@ -64,18 +66,22 @@ const usageByWindowAtom = Atom.family((windowKey: string) => export interface UsageView { readonly merged: MergedUsage; readonly environments: readonly EnvironmentUsageStatus[]; + readonly selectedEnvironments: readonly EnvironmentUsageStatus[]; /** True until at least one environment has answered. */ readonly isPending: boolean; /** * True while environments that have not failed are still answering. Failed - * environments are reported through their own error rows: totals will not + * environments are reported in the environment menu: totals will not * improve by waiting on them, so they must not read as "still reporting". */ readonly isPartial: boolean; readonly refresh: (input?: UsageSummaryInput) => Promise; } -export function useUsage(input: UsageSummaryInput): UsageView { +export function useUsage( + input: UsageSummaryInput, + selectedEnvironmentIds: ReadonlySet | null = null, +): UsageView { const windowKey = useMemo( () => JSON.stringify({ @@ -97,6 +103,13 @@ export function useUsage(input: UsageSummaryInput): UsageView { ); const atom = usageByWindowAtom(windowKey); const environments = useAtomValue(atom); + const selectedEnvironments = useMemo( + () => + selectedEnvironmentIds === null + ? environments + : environments.filter(({ environmentId }) => selectedEnvironmentIds.has(environmentId)), + [environments, selectedEnvironmentIds], + ); const refresh = useCallback( (nextInput?: UsageSummaryInput) => @@ -104,14 +117,14 @@ export function useUsage(input: UsageSummaryInput): UsageView { registry: appAtomRegistry, server: serverEnvironment, presentations: environmentPresentations, - environmentIds: environments.map(({ environmentId }) => environmentId), + environmentIds: selectedEnvironments.map(({ environmentId }) => environmentId), input: nextInput ?? (JSON.parse(windowKey) as UsageSummaryInput), }), - [environments, windowKey], + [selectedEnvironments, windowKey], ); const merged = useMemo(() => { - const answered: EnvironmentUsage[] = environments.flatMap((environment) => + const answered: EnvironmentUsage[] = selectedEnvironments.flatMap((environment) => environment.summary === null ? [] : [ @@ -123,16 +136,19 @@ export function useUsage(input: UsageSummaryInput): UsageView { ], ); return mergeUsage(answered, USAGE_CONTRACT_VERSION); - }, [environments]); + }, [selectedEnvironments]); - const answeredCount = environments.filter((environment) => environment.summary !== null).length; - const stillReporting = environments.filter( + const answeredCount = selectedEnvironments.filter( + (environment) => environment.summary !== null, + ).length; + const stillReporting = selectedEnvironments.filter( (environment) => environment.summary === null && environment.error === null, ).length; return { merged, environments, + selectedEnvironments, isPending: answeredCount === 0 && stillReporting > 0, isPartial: answeredCount > 0 && stillReporting > 0, refresh, diff --git a/docs/user/usage.md b/docs/user/usage.md index 2f3e1013fdce..4c4af3299acb 100644 --- a/docs/user/usage.md +++ b/docs/user/usage.md @@ -39,14 +39,14 @@ the dialog. ## Track subscription limits -On web and desktop, **Usage → Limits** pools every subscription account it can see per provider, so with several Codex +**Usage → Limits** pools every subscription account it can see per provider, so with several Codex or Claude accounts across your environments and hubs you read one number per window rather than a list. Each window card shows how much of the pool is left and a bar with one segment per account, ordered by which resets soonest; when the provider reports reset times, the card also says when the next reset lands and how much it hands back. The hatched -part of a segment is what that reset restores. Tap or hover a segment for the account's plan, where it is -signed in, and its reset time; Codex accounts with banked reset credits show a ticket count on the -segment and the **Use reset** action in that popover. On narrow screens, numbered rows below +part of a segment is what that reset restores. Tap a segment or account row for the account's plan, +where it is signed in, and its reset time. On web, you can hover too. Codex accounts with banked +reset credits show a ticket count and the **Use reset** action in the account details. On narrow screens, numbered rows below the bar show each account's quota, countdown, and credits. Tap a row to open its details. The same account signed in on more than one environment, or reported by a hub as well, counts once. diff --git a/packages/shared/src/usageLimits.ts b/packages/shared/src/usageLimits.ts index 419e1944c538..d3b295bf42fb 100644 --- a/packages/shared/src/usageLimits.ts +++ b/packages/shared/src/usageLimits.ts @@ -209,7 +209,7 @@ export function collectLimitAccounts( // the freshest native snapshot supplies both, or neither. const native = [previous, next] .filter((candidate) => candidate.redeem !== null) - .toSorted((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; + .sort((a, b) => Date.parse(b.limits.checkedAt) - Date.parse(a.limits.checkedAt))[0]; accounts.set(key, { ...previous, displayName: previous.displayName ?? next.displayName, @@ -374,7 +374,7 @@ export function collectLimitPools( else byDriver.set(account.driver, [account]); } return [...byDriver].map(([driver, members]) => { - const sorted = members.toSorted( + const sorted = [...members].sort( (left, right) => Number(left.redeem === null) - Number(right.redeem === null) || accountSortName(left).localeCompare(accountSortName(right)), @@ -398,7 +398,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L } } const pools = [...byKey.values()].map((unordered): LimitPoolWindow => { - const members = unordered.toSorted( + const members = [...unordered].sort( (left, right) => (resetMillis(left.window) ?? Number.POSITIVE_INFINITY) - (resetMillis(right.window) ?? Number.POSITIVE_INFINITY), @@ -428,7 +428,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L }, ]; }) - .toSorted((left, right) => left.at - right.at); + .sort((left, right) => left.at - right.at); return { id: first.id, kind: first.kind, @@ -440,17 +440,7 @@ function poolWindows(accounts: readonly LimitAccount[], now: number): readonly L resets, }; }); - return pools.toSorted( - (left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind], - ); -} - -/** The instance's configured name, else the driver's, else its raw kind. */ -export function providerLimitsLabel( - provider: Pick, - driverLabel: (driver: ServerProvider["driver"]) => string | undefined, -): string { - return provider.displayName?.trim() || driverLabel(provider.driver) || String(provider.driver); + return pools.sort((left, right) => WINDOW_KIND_ORDER[left.kind] - WINDOW_KIND_ORDER[right.kind]); } /** The one-line status under a provider heading when there are no bars to draw. */ @@ -627,7 +617,7 @@ export function collectProviderUsageLimits( accounts.push({ id: provider.instanceId, driver: provider.driver, - label: `${providerLimitsLabel(provider, () => undefined)} [${provider.instanceId}]`, + label: `${provider.displayName?.trim() || String(provider.driver)} [${provider.instanceId}]`, ...(provider.auth.label ? { plan: provider.auth.label } : {}), instanceId: provider.instanceId, ...(provider.displayName ? { displayName: provider.displayName } : {}), From 7544d3d2c8e0145018d9adb7a1a650333b75362a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 01:09:09 -0700 Subject: [PATCH 205/320] fix(release): space automatic nightlies at least six hours apart (#10272) --- .github/scripts/check-nightly-release.cjs | 44 ++++++++ .../scripts/check-nightly-release.test.cjs | 101 ++++++++++++++++++ .github/workflows/ci.yml | 3 + .github/workflows/release.yml | 44 +++----- docs/operations/release.md | 6 +- 5 files changed, 166 insertions(+), 32 deletions(-) create mode 100644 .github/scripts/check-nightly-release.cjs create mode 100644 .github/scripts/check-nightly-release.test.cjs diff --git a/.github/scripts/check-nightly-release.cjs b/.github/scripts/check-nightly-release.cjs new file mode 100644 index 000000000000..dc4b55bc6517 --- /dev/null +++ b/.github/scripts/check-nightly-release.cjs @@ -0,0 +1,44 @@ +const MINIMUM_RELEASE_GAP_MS = 6 * 60 * 60 * 1000; + +// Runs after the workflow acquires the nightly concurrency lock. +async function shouldReleaseNightly({ github, context, core, now = Date.now() }) { + const releases = await github.paginate(github.rest.repos.listReleases, { + ...context.repo, + per_page: 100, + }); + const lastNightly = releases + .filter( + (release) => + !release.draft && + release.published_at && + (/^v.*-nightly\./.test(release.tag_name) || release.tag_name.startsWith("nightly-v")), + ) + .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; + + if (!lastNightly) { + core.info("No published nightly found. Proceeding with release."); + return true; + } + + if (now - Date.parse(lastNightly.published_at) < MINIMUM_RELEASE_GAP_MS) { + core.info(`Nightly ${lastNightly.tag_name} was published less than six hours ago. Skipping.`); + return false; + } + + const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ + ...context.repo, + basehead: `${lastNightly.tag_name}...${context.sha}`, + per_page: 1, + }); + if (comparison.status !== "ahead") { + core.info( + `Candidate commit is ${comparison.status} relative to ${lastNightly.tag_name}. Skipping.`, + ); + return false; + } + + core.info(`New commits since ${lastNightly.tag_name}, and the six-hour gap has passed.`); + return true; +} + +module.exports = { shouldReleaseNightly }; diff --git a/.github/scripts/check-nightly-release.test.cjs b/.github/scripts/check-nightly-release.test.cjs new file mode 100644 index 000000000000..476773bc4e5a --- /dev/null +++ b/.github/scripts/check-nightly-release.test.cjs @@ -0,0 +1,101 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); +const { shouldReleaseNightly } = require("./check-nightly-release.cjs"); + +const now = Date.parse("2026-09-05T12:00:00Z"); +const hour = 60 * 60 * 1000; +const nightly = (hoursAgo, overrides = {}) => ({ + tag_name: "v1.0.1-nightly.20260905.123", + draft: false, + published_at: new Date(now - hoursAgo * hour).toISOString(), + ...overrides, +}); + +function fixture({ releases = [nightly(7)], comparisonStatus = "ahead" } = {}) { + const calls = []; + return { + calls, + options: { + now, + context: { repo: { owner: "example", repo: "app" }, sha: "new" }, + core: { info() {} }, + github: { + rest: { + repos: { + listReleases() {}, + async compareCommitsWithBasehead(params) { + calls.push(params); + return { data: { status: comparisonStatus } }; + }, + }, + }, + async paginate() { + return releases; + }, + }, + }, + }; +} + +test("releases the first nightly when no nightly is published", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true })], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("waits six hours after publication, including manual nightlies", async () => { + for (const age of [0, 3, 6 - 1 / 3600]) { + const { options, calls } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), false); + assert.equal(calls.length, 0); + } +}); + +test("releases new commits at six hours and after an idle period", async () => { + for (const age of [6, 7, 24]) { + const { options } = fixture({ releases: [nightly(age)] }); + assert.equal(await shouldReleaseNightly(options), true); + } +}); + +test("skips unchanged commits after the gap", async () => { + const { options } = fixture({ comparisonStatus: "identical" }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("uses publication time, not release order or the tagged commit date", async () => { + const { options } = fixture({ + releases: [nightly(10), nightly(1), nightly(20, { tag_name: "nightly-v0.9.0" })], + }); + assert.equal(await shouldReleaseNightly(options), false); +}); + +test("ignores stable releases and drafts when checking the gap", async () => { + const { options } = fixture({ + releases: [nightly(0, { tag_name: "v1.0.0" }), nightly(0, { draft: true }), nightly(7)], + }); + assert.equal(await shouldReleaseNightly(options), true); +}); + +test("compares against the published tag, including legacy nightly tags", async () => { + const tag = "nightly-v0.9.0"; + const { options, calls } = fixture({ releases: [nightly(7, { tag_name: tag })] }); + assert.equal(await shouldReleaseNightly(options), true); + assert.equal(calls[0].basehead, `${tag}...new`); +}); + +test("fails instead of releasing when GitHub cannot supply release state", async () => { + const { options } = fixture(); + options.github.paginate = async () => { + throw new Error("GitHub unavailable"); + }; + await assert.rejects(shouldReleaseNightly(options), /GitHub unavailable/); +}); + +for (const status of ["behind", "diverged"]) { + test(`skips a candidate commit that is ${status} relative to the last nightly`, async () => { + const { options } = fixture({ comparisonStatus: status }); + assert.equal(await shouldReleaseNightly(options), false); + }); +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4bcb1e21ab99..e23dbd60d961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -103,6 +103,9 @@ jobs: sudo sed -i 's|http://|https://|g' /etc/apt/blacksmith-ubuntu-mirrors.txt /etc/apt/sources.list.d/ubuntu.sources sudo apt-get update && sudo apt-get install -y libsecret-1-dev pkg-config + - name: Test nightly release checks + run: node --test .github/scripts/check-nightly-release.test.cjs + - name: Test run: vp run --parallel --concurrency-limit 4 --filter '!t3' --filter '!@t3tools/monorepo' test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2785ecb8fa78..48cd451e3fea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,8 +6,8 @@ on: - "v*.*.*" - "!v*-nightly.*" schedule: - # Off minute zero: GitHub delays scheduled runs most at the top of the hour. - - cron: "38 */3 * * *" + # Avoid minute zero, when GitHub scheduled jobs are busiest. + - cron: "8,38 * * * *" workflow_dispatch: inputs: channel: @@ -28,7 +28,7 @@ on: # own group so a nightly never blocks them. Running publishers are never # canceled, and queue: max keeps every pending run instead of the default # newest-wins single slot, so a queued stable tag can never be silently -# dropped. Queued nightlies with no new commits skip via check_changes. +# dropped. Automatic nightlies recheck the release gap after leaving the queue. concurrency: group: release-${{ (github.event_name == 'schedule' || inputs.channel == 'nightly') && 'nightly' || 'stable' }} cancel-in-progress: false @@ -40,41 +40,25 @@ permissions: jobs: check_changes: - name: Check for changes since last nightly + name: Check automatic nightly release if: github.event_name == 'schedule' runs-on: blacksmith-8vcpu-ubuntu-2404 + timeout-minutes: 5 outputs: - has_changes: ${{ steps.check.outputs.has_changes }} + has_changes: ${{ steps.check.outputs.result }} steps: - name: Checkout uses: actions/checkout@v6 with: - fetch-depth: 0 - sparse-checkout: | - /* - !/.repos/ - sparse-checkout-cone-mode: false + sparse-checkout: .github/scripts - id: check - name: Compare HEAD to last nightly tag - run: | - last_nightly_tag=$(git tag --list 'v*-nightly.*' 'nightly-v*' --sort=-creatordate | head -n 1) - if [[ -z "$last_nightly_tag" ]]; then - echo "No previous nightly tag found. Proceeding with release." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - exit 0 - fi - - last_nightly_sha=$(git rev-parse "$last_nightly_tag^{commit}") - head_sha=$(git rev-parse HEAD) - - if [[ "$last_nightly_sha" == "$head_sha" ]]; then - echo "No changes on main since last nightly release ($last_nightly_tag). Skipping." - echo "has_changes=false" >> "$GITHUB_OUTPUT" - else - echo "Changes detected on main since $last_nightly_tag ($last_nightly_sha → $head_sha). Proceeding." - echo "has_changes=true" >> "$GITHUB_OUTPUT" - fi + name: Check release gap and new commits + uses: actions/github-script@v8 + with: + script: | + const { shouldReleaseNightly } = require('./.github/scripts/check-nightly-release.cjs'); + return await shouldReleaseNightly({ github, context, core }); preflight: name: Preflight @@ -228,7 +212,7 @@ jobs: name: Resolve T3 Connect public config # Consumes only the commit SHA, not preflight's resolved version, so it runs # alongside preflight instead of after it. The condition mirrors preflight's: - # check_changes is skipped on non-schedule events (skipped is neither failure + # check_changes is skipped on manual and tag releases (skipped is neither failure # nor success, so success() would be wrong here). needs: [check_changes] if: | diff --git a/docs/operations/release.md b/docs/operations/release.md index 02f453ab36a5..4217c76f1f1e 100644 --- a/docs/operations/release.md +++ b/docs/operations/release.md @@ -9,7 +9,7 @@ This document covers the unified release workflow for stable and nightly desktop - Workflow: `.github/workflows/release.yml` - Triggers: - push tag matching `v*.*.*` for stable releases - - scheduled nightly check every three hours + - scheduled nightly check every 30 minutes - manual `workflow_dispatch` for either channel - Runs lint, typecheck, and tests alongside artifact builds. Publishing waits for every check. - Reads the shared production T3 Connect relay URL and Clerk client configuration before packaging clients. @@ -158,8 +158,10 @@ One-time Vercel dashboard setup: - Workflow: `.github/workflows/release.yml` - Triggers: - - scheduled check every three hours + - scheduled check every 30 minutes - manual `workflow_dispatch` with `channel=nightly` +- Automatic nightlies require new commits and at least six hours since the last nightly was published, including manual nightlies. +- Manual nightlies bypass the time and change checks. Nightly runs remain serialized. Scheduled runs wait for an active nightly to finish, then check the publication gap before building. - Runs the same desktop quality gates and artifact matrix as the tagged release flow. - Publishes a GitHub prerelease only: - current tag format: `vX.Y.Z-nightly.YYYYMMDD.` From dd64072917193195479f6907cfb081267bff7301 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:15:13 -0700 Subject: [PATCH 206/320] refactor(web): share bulk thread deletion between sidebars (#10106) --- apps/web/src/components/LegacySidebar.tsx | 23 ++--- apps/web/src/components/Sidebar.logic.test.ts | 99 +++++++++++++++++++ apps/web/src/components/Sidebar.logic.ts | 35 +++++++ apps/web/src/components/Sidebar.tsx | 33 +++---- 4 files changed, 155 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index 1093157710be..fd1343c115c8 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -179,6 +179,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { archiveSelectedThreadEntries, buildMultiSelectThreadContextMenuItems, + deleteSelectedThreadEntries, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, isContextMenuPointerDown, @@ -1926,21 +1927,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec if (!confirmed) return; } - // Only discount batch members after their deletions succeed. - const deletedThreadKeys = new Set(); - let firstError: unknown = null; - for (const { threadKey, threadRef } of selectedThreadEntries) { - const result = await deleteThread(threadRef, { - deletedThreadKeys, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) break; - firstError ??= squashAtomCommandFailure(result); - continue; - } - deletedThreadKeys.add(threadKey); - } - if (firstError !== null) { + const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ + entries: selectedThreadEntries, + delete: ({ threadRef }, deletedThreadKeys) => + deleteThread(threadRef, { deletedThreadKeys }), + }); + if (firstFailure !== null) { + const firstError = squashAtomCommandFailure(firstFailure); toastManager.add( stackedThreadToast({ type: "error", diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index cd56835ffd8b..fee7ef181a59 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/unstable/reactivity"; import { animatePinnedLayoutChanges, archiveSelectedThreadEntries, @@ -7,6 +9,7 @@ import { buildBulkUnpinContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + deleteSelectedThreadEntries, filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, resolveAdjacentThreadId, @@ -81,6 +84,102 @@ describe("animatePinnedLayoutChanges", () => { }); }); +describe("deleteSelectedThreadEntries", () => { + const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; + const success = AsyncResult.success(undefined); + const failure = AsyncResult.failure(Cause.fail(new Error("Delete failed"))); + const interrupted = AsyncResult.failure(Cause.interrupt()); + + it("waits for each delete and excludes only earlier successes from worktree checks", async () => { + let resolveDelete!: (result: typeof success) => void; + const pendingDelete = new Promise((resolve) => { + resolveDelete = resolve; + }); + const worktreeChecks: { threadKey: string; deletedThreadKeys: string[] }[] = []; + const deletion = deleteSelectedThreadEntries({ + entries, + delete: async ({ threadKey }, deletedThreadKeys) => { + worktreeChecks.push({ threadKey, deletedThreadKeys: [...deletedThreadKeys] }); + return threadKey === "one" ? pendingDelete : success; + }, + }); + + expect(worktreeChecks).toEqual([{ threadKey: "one", deletedThreadKeys: [] }]); + resolveDelete(success); + const outcome = await deletion; + + expect(worktreeChecks).toEqual([ + { threadKey: "one", deletedThreadKeys: [] }, + { threadKey: "two", deletedThreadKeys: ["one"] }, + { threadKey: "three", deletedThreadKeys: ["one", "two"] }, + ]); + expect(outcome).toEqual({ + deletedThreadKeys: new Set(["one", "two", "three"]), + firstFailure: null, + }); + }); + + it("continues after ordinary failures and keeps the first failure", async () => { + const laterFailure = AsyncResult.failure(Cause.fail(new Error("Later failure"))); + const deletedKeysAtLastEntry: string[][] = []; + const outcome = await deleteSelectedThreadEntries({ + entries: [...entries, { threadKey: "four" }], + delete: async ({ threadKey }, deletedThreadKeys) => { + if (threadKey === "one") return failure; + if (threadKey === "three") return laterFailure; + if (threadKey === "four") deletedKeysAtLastEntry.push([...deletedThreadKeys]); + return success; + }, + }); + + expect(deletedKeysAtLastEntry).toEqual([["two"]]); + expect(outcome).toEqual({ + deletedThreadKeys: new Set(["two", "four"]), + firstFailure: failure, + }); + }); + + it.each([ + { firstResult: success, deletedThreadKeys: new Set(["one"]), firstFailure: null }, + { firstResult: failure, deletedThreadKeys: new Set(), firstFailure: failure }, + ])("stops on interruption and preserves earlier results %#", async (testCase) => { + const attemptedThreadKeys: string[] = []; + const outcome = await deleteSelectedThreadEntries({ + entries, + delete: async ({ threadKey }) => { + attemptedThreadKeys.push(threadKey); + return threadKey === "one" ? testCase.firstResult : interrupted; + }, + }); + + expect(attemptedThreadKeys).toEqual(["one", "two"]); + expect(outcome).toEqual({ + deletedThreadKeys: testCase.deletedThreadKeys, + firstFailure: testCase.firstFailure, + }); + }); + + it("does not count a skipped entry as deleted", async () => { + const visibleEntries = new Set(entries.map(({ threadKey }) => threadKey)); + const worktreeChecks: string[][] = []; + const outcome = await deleteSelectedThreadEntries({ + entries, + delete: async ({ threadKey }, deletedThreadKeys) => { + if (!visibleEntries.has(threadKey)) return null; + worktreeChecks.push([...deletedThreadKeys]); + visibleEntries.delete("two"); + return success; + }, + }); + + expect(worktreeChecks).toEqual([[], ["one"]]); + expect(outcome).toEqual({ + deletedThreadKeys: new Set(["one", "three"]), + firstFailure: null, + }); + }); +}); + describe("archiveSelectedThreadEntries", () => { const entries = [{ threadKey: "one" }, { threadKey: "two" }, { threadKey: "three" }] as const; const success = { _tag: "Success" } as const; diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 194db3130011..de06237ae41d 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -1,7 +1,12 @@ import * as React from "react"; import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit/sortable"; +import { + isAtomCommandInterrupted, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; +import type { AsyncResult } from "effect/unstable/reactivity"; import { activeThreadAnchorTimestampMs, getThreadSortTimestamp, @@ -109,6 +114,36 @@ type LogicalSidebarProject = SidebarProject & { export type ThreadTraversalDirection = "previous" | "next"; +/** + * Shared-worktree checks must exclude only successful deletions, never the + * whole batch. A null result skips an entry that the caller can no longer find. + */ +export async function deleteSelectedThreadEntries< + TEntry extends { readonly threadKey: string }, +>(input: { + entries: readonly TEntry[]; + delete: ( + entry: TEntry, + deletedThreadKeys: ReadonlySet, + ) => Promise | null>; +}) { + const deletedThreadKeys = new Set(); + let firstFailure: AsyncResult.Failure | null = null; + + for (const entry of input.entries) { + const result = await input.delete(entry, deletedThreadKeys); + if (result === null) continue; + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) break; + firstFailure ??= result; + continue; + } + deletedThreadKeys.add(entry.threadKey); + } + + return { deletedThreadKeys, firstFailure }; +} + export async function archiveSelectedThreadEntries< TEntry extends { readonly threadKey: string }, TResult extends { readonly _tag: "Success" | "Failure" }, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 257a040811ba..f57872291f8b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -140,6 +140,7 @@ import { animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, + deleteSelectedThreadEntries, filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, @@ -3219,26 +3220,18 @@ export default function Sidebar() { ); if (confirmed._tag === "Failure" || !confirmed.value) return; } - // Grown as deletions actually land, never seeded with the whole batch: - // orphaned-worktree detection must only discount threads that are - // really gone, or the first delete would treat still-alive batch mates - // as deleted and remove a worktree they still point at. - const deletedThreadKeys = new Set(); - let firstError: unknown = null; - for (const threadKey of threadKeys) { - const thread = threadByKeyRef.current.get(threadKey); - if (!thread) continue; - const result = await deleteThread(scopeThreadRef(thread.environmentId, thread.id), { - deletedThreadKeys, - }); - if (result._tag === "Failure") { - if (isAtomCommandInterrupted(result)) break; - firstError ??= squashAtomCommandFailure(result); - continue; - } - deletedThreadKeys.add(threadKey); - } - if (firstError !== null) { + const { deletedThreadKeys, firstFailure } = await deleteSelectedThreadEntries({ + entries: threadKeys.map((threadKey) => ({ threadKey })), + delete: async ({ threadKey }, deletedThreadKeys) => { + const thread = threadByKeyRef.current.get(threadKey); + if (!thread) return null; + return deleteThread(scopeThreadRef(thread.environmentId, thread.id), { + deletedThreadKeys, + }); + }, + }); + if (firstFailure !== null) { + const firstError = squashAtomCommandFailure(firstFailure); toastManager.add( stackedThreadToast({ type: "error", From ac11bd29b03ec568f2616c78d885574eff16ad3c Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:15:19 -0700 Subject: [PATCH 207/320] refactor(client): share tool outcome rules (#10122) --- apps/mobile/src/lib/threadActivity.ts | 89 +--------- apps/web/src/session-logic.test.ts | 140 ++++------------ apps/web/src/session-logic.ts | 157 ++---------------- .../src/work-log/presentation.test.ts | 122 ++++++++++++++ .../src/work-log/presentation.ts | 92 +++++++++- 5 files changed, 259 insertions(+), 341 deletions(-) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 7286446fb2e8..f0ba11a0ccaa 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -16,6 +16,7 @@ import { formatDuration } from "@t3tools/shared/orchestrationTiming"; import { commandDetailRepeatsCommand, extractCommandOutputText, + extractWorkLogToolLifecycleStatus, isWorktreeSetupActivity, liveActivityToolStatus, normalizeCompactToolLabel, @@ -24,7 +25,11 @@ import { summarizeToolGroup, toolGroupAction, toolGroupSummaryKind, + workEntryIndicatesToolFailure, + workEntryIndicatesToolSuccess, + workLogEntryIsToolLike, type ToolGroupSummaryKind, + type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { commandProgramName } from "@t3tools/client-runtime/work-log/command-label"; @@ -88,8 +93,6 @@ export interface ThreadFeedActivity { readonly live?: boolean; } -type WorkLogToolLifecycleStatus = "inProgress" | "completed" | "failed" | "declined" | "stopped"; - export interface WorkLogEntry { id: string; createdAt: string; @@ -989,67 +992,6 @@ function deriveToolLifecycleCollapseKey(entry: DerivedWorkLogEntry): string | un return [itemType, normalizedLabel, detail].join("\u001f"); } -function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { - if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { - return true; - } - if (entry.command !== undefined && entry.command.trim().length > 0) { - return true; - } - if (entry.requestKind !== undefined) { - return true; - } - return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); -} - -function toolDetailTextLooksLikeFailure(text: string): boolean { - const normalized = text.toLowerCase(); - return ( - normalized.includes("file not found") || - normalized.includes("no files found") || - normalized.includes("enoent") || - normalized.includes("no such file or directory") || - normalized.includes("no such file") || - normalized.includes("commandnotfoundexception") || - normalized.includes("command not found") || - (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || - (normalized.includes("is not recognized") && normalized.includes("the term '")) || - normalized.includes("is not recognized as the name of a cmdlet") || - normalized.includes("a parameter cannot be found that matches parameter name") || - //i.test(text) || - /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || - /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) - ); -} - -function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { - if (entry.tone === "error") { - return true; - } - if (entry.toolLifecycleStatus === "failed" || entry.toolLifecycleStatus === "declined") { - return true; - } - if (!workLogEntryIsToolLike(entry)) { - return false; - } - return toolDetailTextLooksLikeFailure([entry.detail, entry.command].filter(Boolean).join("\n")); -} - -function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { - if (!workLogEntryIsToolLike(entry) || workEntryIndicatesToolFailure(entry)) { - return false; - } - if (entry.tone === "thinking") { - return false; - } - return ( - entry.toolLifecycleStatus !== "inProgress" && - entry.toolLifecycleStatus !== "stopped" && - entry.toolLifecycleStatus !== "failed" && - entry.toolLifecycleStatus !== "declined" - ); -} - function workEntryStatus(entry: WorkLogEntry): ThreadFeedActivity["status"] { if (entry.agentSpawn) { switch (entry.toolLifecycleStatus) { @@ -1521,27 +1463,6 @@ function extractToolTitle(payload: Record | null): string | nul return asTrimmedString(payload?.title); } -function extractWorkLogToolLifecycleStatus( - payload: Record | null, -): WorkLogToolLifecycleStatus | undefined { - const status = payload?.status; - // The parent turn ended, so batch tracking is inactive. The detail explains - // that child status is unavailable; do not retain the earlier running marker. - if (status === "idle" && payload?.taskType === "subagent_batch") return "stopped"; - if (status === "pending" || status === "running" || status === "waiting") return "inProgress"; - if (status === "cancelled" || status === "interrupted") return "stopped"; - if ( - status === "inProgress" || - status === "completed" || - status === "failed" || - status === "declined" || - status === "stopped" - ) { - return status; - } - return undefined; -} - function stripTrailingExitCode(value: string): { output: string | null; exitCode?: number | undefined; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index dfb0c49be614..f9b888fd1dd1 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -23,9 +23,7 @@ import { isLatestTurnSettled, selectHandoffImageResources, selectMessageImageResources, - workEntryIndicatesToolFailure, workEntryIndicatesToolNeutralStatus, - workEntryIndicatesToolSuccess, } from "./session-logic"; let nextActivityId = 0; @@ -809,121 +807,43 @@ describe("hasActionableProposedPlan", () => { }); }); -describe("workEntryIndicatesToolFailure", () => { - const base = { - id: "w1", - createdAt: "2026-01-01T00:00:00.000Z", - label: "Read", - }; - - it("is true for error tone", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "error", - detail: "nothing special", - }), - ).toBe(true); - }); - - it("is true when lifecycle says failed even if detail is empty", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "tool", - toolLifecycleStatus: "failed", - }), - ).toBe(true); - }); - - it("detects file-not-found style tool output with completed lifecycle", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "File not found: C:\\foo\\nonexistent.ts", - }), - ).toBe(true); - }); - - it("detects glob no files and PowerShell command errors", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - label: "Glob", - tone: "tool", - detail: "No files found", - }), - ).toBe(true); - expect( - workEntryIndicatesToolFailure({ - ...base, - label: "Bash", - tone: "tool", - detail: - "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", - }), - ).toBe(true); - }); - - it("is false for successful completed tools", () => { - expect( - workEntryIndicatesToolFailure({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "Found 3 matching files", - }), - ).toBe(false); - }); - - it("treats successful tool rows as success candidates", () => { - expect( - workEntryIndicatesToolSuccess({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "ok", - }), - ).toBe(true); - expect( - workEntryIndicatesToolSuccess({ - ...base, - tone: "tool", - toolLifecycleStatus: "inProgress", - detail: "…", - }), - ).toBe(false); - expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); - expect( - workEntryIndicatesToolNeutralStatus({ - ...base, - tone: "tool", - toolLifecycleStatus: "inProgress", - detail: "…", - }), - ).toBe(true); +describe("workEntryIndicatesToolNeutralStatus", () => { + it("keeps active tools neutral and agent spawns visible", () => { + const entry = { + id: "work-1", + createdAt: "2026-01-01T00:00:00.000Z", + label: "Read", + tone: "tool" as const, + toolLifecycleStatus: "inProgress" as const, + }; + expect(workEntryIndicatesToolNeutralStatus(entry)).toBe(true); expect( workEntryIndicatesToolNeutralStatus({ - ...base, - tone: "tool", - toolLifecycleStatus: "completed", - detail: "ok", + ...entry, + agentSpawn: { workflowId: null, agentTaskIds: ["agent-1"] }, }), ).toBe(false); - }); - - it("does not run heuristics on non-tool info rows", () => { expect( - workEntryIndicatesToolFailure({ - ...base, - label: "Context compacted", - tone: "info", - detail: "File not found in conversation", - }), + workEntryIndicatesToolNeutralStatus({ ...entry, toolLifecycleStatus: "completed" }), ).toBe(false); }); + + it.each(["waiting", "cancelled", "interrupted"])( + "keeps the status of a %s background task", + (status) => { + const entries = deriveWorkLogEntries([ + makeActivity({ + kind: "task.progress", + payload: { taskId: "background-1", agentKind: "background", status }, + }), + ]); + expect(entries).toHaveLength(1); + expect(entries[0]).toMatchObject({ + toolLifecycleStatus: status === "waiting" ? "inProgress" : "stopped", + }); + expect(workEntryIndicatesToolNeutralStatus(entries[0]!)).toBe(true); + }, + ); }); describe("deriveWorkLogEntries", () => { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index b6a6eb0e6342..4e56d742bc65 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -6,7 +6,12 @@ import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagent import { commandDetailRepeatsCommand, extractCommandOutputText, + extractWorkLogToolLifecycleStatus, isWorktreeSetupActivity, + workEntryIndicatesToolFailure, + workEntryIndicatesToolSuccess, + workLogEntryIsToolLike, + type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { @@ -37,12 +42,12 @@ import { export { formatDuration } from "@t3tools/shared/orchestrationTiming"; -export type WorkLogToolLifecycleStatus = - | "inProgress" - | "completed" - | "failed" - | "declined" - | "stopped"; +export { + workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, + workLogEntryIsToolLike, + type WorkLogToolLifecycleStatus, +} from "@t3tools/client-runtime/work-log/presentation"; export interface WorkLogEntry { id: string; @@ -167,103 +172,6 @@ export interface TimelineEntriesProjection { readonly entries: TimelineEntry[]; } -export function workLogEntryIsToolLike(entry: WorkLogEntry): boolean { - if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") { - return true; - } - if (entry.command !== undefined && entry.command.trim().length > 0) { - return true; - } - if (entry.requestKind !== undefined) { - return true; - } - return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); -} - -/** Heuristic: providers often emit successful lifecycle status while error text lives in `detail` / `command`. */ -function toolDetailTextLooksLikeFailure(text: string): boolean { - const t = text.toLowerCase(); - if (t.includes("file not found")) { - return true; - } - if (t.includes("no files found")) { - return true; - } - if ( - t.includes("enoent") || - t.includes("no such file or directory") || - t.includes("no such file") - ) { - return true; - } - if (t.includes("cannot find path") && t.includes("because it does not exist")) { - return true; - } - if (t.includes("commandnotfoundexception")) { - return true; - } - if (t.includes("is not recognized as the name of a cmdlet")) { - return true; - } - if (t.includes("is not recognized") && t.includes("the term '")) { - return true; - } - if (t.includes("a parameter cannot be found that matches parameter name")) { - return true; - } - if (t.includes("command not found")) { - return true; - } - if (//i.test(text)) { - return true; - } - if (/exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text)) { - return true; - } - if (/exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text)) { - return true; - } - return false; -} - -function workEntryIndicatesToolFailureFromOutput( - entry: WorkLogEntry, - includeCommand: boolean, -): boolean { - if (entry.tone === "error") { - return true; - } - const ls = entry.toolLifecycleStatus; - if (ls === "failed" || ls === "declined") { - return true; - } - if (!workLogEntryIsToolLike(entry)) { - return false; - } - const parts: string[] = []; - if (entry.detail) { - parts.push(entry.detail); - } - if (includeCommand && entry.command) { - parts.push(entry.command); - } - const blob = parts.join("\n"); - if (blob.length === 0) { - return false; - } - return toolDetailTextLooksLikeFailure(blob); -} - -/** True when a tool failed, including providers that put error output in `command`. */ -export function workEntryIndicatesToolFailure(entry: WorkLogEntry): boolean { - return workEntryIndicatesToolFailureFromOutput(entry, true); -} - -/** True when the rendered result indicates failure. The command itself is user intent, not output. */ -export function workEntryDisplayIndicatesToolFailure(entry: WorkLogEntry): boolean { - return workEntryIndicatesToolFailureFromOutput(entry, false); -} - /** Severe failures keep the red treatment ordinary tool failures lost: runtime * errors and orchestration `*.failed` activities (provider.turn.start.failed, * checkpoint.capture.failed, ...) mean the turn or a core side effect broke, @@ -275,30 +183,6 @@ export function workEntrySignalsSevereFailure(entry: WorkLogEntry): boolean { ); } -/** Tool/command row completed without failure (blue check affordance). */ -export function workEntryIndicatesToolSuccess(entry: WorkLogEntry): boolean { - if (!workLogEntryIsToolLike(entry)) { - return false; - } - if (workEntryIndicatesToolFailure(entry)) { - return false; - } - if (entry.tone === "thinking") { - return false; - } - const ls = entry.toolLifecycleStatus; - if (ls === "failed" || ls === "declined") { - return false; - } - if (ls === "inProgress") { - return false; - } - if (ls === "stopped") { - return false; - } - return true; -} - /** Tool-like row with neither clear success nor failure (empty, incomplete, in progress, etc.). */ export function workEntryIndicatesToolNeutralStatus(entry: WorkLogEntry): boolean { // Spawn CTA rows are never neutral-hidden: mid-run they derive from @@ -827,25 +711,6 @@ function isPlanBoundaryToolActivity(activity: OrchestrationThreadActivity): bool return typeof payload?.detail === "string" && payload.detail.startsWith("ExitPlanMode:"); } -function extractWorkLogToolLifecycleStatus( - payload: Record | null, -): WorkLogToolLifecycleStatus | undefined { - if (!payload) { - return undefined; - } - const s = payload.status; - if ( - s === "inProgress" || - s === "completed" || - s === "failed" || - s === "declined" || - s === "stopped" - ) { - return s; - } - return undefined; -} - function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWorkLogEntry { const cachedEntry = derivedWorkLogEntryByActivity.get(activity); if (cachedEntry) { diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index f3febf5e756d..e056e92afe5c 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -12,8 +12,130 @@ import { toolGroupSummaryKind, type WorkLogPresentationEntry, workEntryViewedImagePath, + workEntryIndicatesToolFailure, + workEntryDisplayIndicatesToolFailure, + workEntryIndicatesToolSuccess, } from "./presentation.js"; +describe("workEntryIndicatesToolFailure", () => { + const base = { + id: "w1", + createdAt: "2026-01-01T00:00:00.000Z", + label: "Read", + }; + + it("is true for error tone", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "error", + detail: "nothing special", + }), + ).toBe(true); + }); + + it("is true when lifecycle says failed even if detail is empty", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "tool", + toolLifecycleStatus: "failed", + }), + ).toBe(true); + }); + + it("detects file-not-found style tool output with completed lifecycle", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "tool", + toolLifecycleStatus: "completed", + detail: "File not found: C:\\foo\\nonexistent.ts", + }), + ).toBe(true); + }); + + it("detects glob no files and PowerShell command errors", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + label: "Glob", + tone: "tool", + detail: "No files found", + }), + ).toBe(true); + expect( + workEntryIndicatesToolFailure({ + ...base, + label: "Bash", + tone: "tool", + detail: + "The term 'this_is_not_a_command' is not recognized as the name of a cmdlet, function, script file, or operable program.", + }), + ).toBe(true); + }); + + it("is false for successful completed tools", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + tone: "tool", + toolLifecycleStatus: "completed", + detail: "Found 3 matching files", + }), + ).toBe(false); + }); + + it("does not treat error text in a command as rendered failure", () => { + const entry = { + label: "Ran command", + tone: "tool", + toolLifecycleStatus: "completed", + command: 'rg "file not found"', + detail: "Found 3 matches", + } satisfies WorkLogPresentationEntry; + + expect(workEntryDisplayIndicatesToolFailure(entry)).toBe(false); + // Older activities can store output in this field, so that path stays separate. + expect(workEntryIndicatesToolFailure(entry)).toBe(true); + expect(workEntryDisplayIndicatesToolFailure({ ...entry, detail: "File not found" })).toBe(true); + }); + + it("treats successful tool rows as success candidates", () => { + expect( + workEntryIndicatesToolSuccess({ + ...base, + tone: "tool", + toolLifecycleStatus: "completed", + detail: "ok", + }), + ).toBe(true); + expect( + workEntryIndicatesToolSuccess({ + ...base, + tone: "tool", + toolLifecycleStatus: "inProgress", + detail: "…", + }), + ).toBe(false); + expect(workEntryIndicatesToolSuccess({ ...base, tone: "thinking", detail: "…" })).toBe(false); + expect( + workEntryIndicatesToolSuccess({ ...base, tone: "tool", toolLifecycleStatus: "stopped" }), + ).toBe(false); + }); + + it("does not run heuristics on non-tool info rows", () => { + expect( + workEntryIndicatesToolFailure({ + ...base, + label: "Context compacted", + tone: "info", + detail: "File not found in conversation", + }), + ).toBe(false); + }); +}); + describe("summarizeToolGroup", () => { it.each(["command", "file-read", "file-change"])( "keeps %s approvals out of tool execution counts", diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 050bb7145d45..2e1ef3bbf003 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,6 +1,7 @@ import { isToolLifecycleItemType, type AssetResource, + type RuntimeItemStatus, type ThreadId, type ToolActivitySource, type ToolLifecycleItemType, @@ -13,6 +14,8 @@ export function isWorktreeSetupActivity(kind: string): boolean { return kind === "setup-script.requested" || kind === "setup-script.started"; } +export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped"; + export interface WorkLogPresentationEntry { readonly label: string; readonly toolTitle?: string; @@ -298,13 +301,100 @@ export function commandDetailRepeatsCommand(input: { ); } -function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { +export function workLogEntryIsToolLike(entry: WorkLogPresentationEntry): boolean { if (entry.tone === "tool" || entry.tone === "thinking" || entry.tone === "error") return true; if (entry.command !== undefined && entry.command.trim().length > 0) return true; if (entry.requestKind !== undefined) return true; return entry.itemType !== undefined && isToolLifecycleItemType(entry.itemType); } +/** Maps item and task status to the status shown on a work-log row. */ +export function extractWorkLogToolLifecycleStatus( + payloadValue: unknown, +): WorkLogToolLifecycleStatus | undefined { + const payload = asRecord(payloadValue); + switch (payload?.status) { + case "pending": + case "running": + case "waiting": + return "inProgress"; + case "cancelled": + case "interrupted": + return "stopped"; + case "idle": + // A batch becomes idle when its parent turn ends. Other idle tasks can resume. + return payload.taskType === "subagent_batch" ? "stopped" : undefined; + case "inProgress": + case "completed": + case "failed": + case "declined": + case "stopped": + return payload.status; + default: + return undefined; + } +} + +// Some providers report completion even when the output describes a failure. +function toolDetailTextLooksLikeFailure(text: string): boolean { + const normalized = text.toLowerCase(); + return ( + normalized.includes("file not found") || + normalized.includes("no files found") || + normalized.includes("enoent") || + normalized.includes("no such file or directory") || + normalized.includes("no such file") || + normalized.includes("commandnotfoundexception") || + normalized.includes("command not found") || + (normalized.includes("cannot find path") && normalized.includes("because it does not exist")) || + (normalized.includes("is not recognized") && normalized.includes("the term '")) || + normalized.includes("is not recognized as the name of a cmdlet") || + normalized.includes("a parameter cannot be found that matches parameter name") || + //i.test(text) || + /exit(?:ed)? with exit code\s+[1-9]\d*/i.test(text) || + /exit code\s*[:\s]\s*[1-9]\d*\b/i.test(text) + ); +} + +function workEntryIndicatesToolFailureFromOutput( + entry: WorkLogPresentationEntry, + includeCommand: boolean, +): boolean { + if ( + entry.tone === "error" || + entry.toolLifecycleStatus === "failed" || + entry.toolLifecycleStatus === "declined" + ) { + return true; + } + if (!workLogEntryIsToolLike(entry)) return false; + const output = includeCommand + ? [entry.detail, entry.command].filter(Boolean).join("\n") + : (entry.detail ?? ""); + return output.length > 0 && toolDetailTextLooksLikeFailure(output); +} + +/** Includes legacy activities that stored error output in the command field. */ +export function workEntryIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { + return workEntryIndicatesToolFailureFromOutput(entry, true); +} + +/** Checks rendered output without treating the user's command as an error. */ +export function workEntryDisplayIndicatesToolFailure(entry: WorkLogPresentationEntry): boolean { + return workEntryIndicatesToolFailureFromOutput(entry, false); +} + +/** Decides whether the row can show a success marker. */ +export function workEntryIndicatesToolSuccess(entry: WorkLogPresentationEntry): boolean { + return ( + workLogEntryIsToolLike(entry) && + !workEntryIndicatesToolFailure(entry) && + entry.tone !== "thinking" && + entry.toolLifecycleStatus !== "inProgress" && + entry.toolLifecycleStatus !== "stopped" + ); +} + function workLogEntryIsLocalCodeSearch(entry: WorkLogPresentationEntry): boolean { return ( entry.itemType === "web_search" && From 4c7cd17a832269ef17edcf99e987ac25ae1b640a Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:15:40 -0700 Subject: [PATCH 208/320] refactor(server): share Claude result status and error mapping (#10296) --- .../src/provider/Layers/ClaudeAdapter.test.ts | 48 ++++++++++ .../src/provider/Layers/ClaudeAdapter.ts | 87 ++++++------------- 2 files changed, 73 insertions(+), 62 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 84cc03b75a6d..13b44c1669fa 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -2253,6 +2253,54 @@ describe("ClaudeAdapterLive", () => { return Effect.forEach(reasons, runDeadTurn, { discard: true }); }); + it.effect.each(["success", "error_during_execution"] as const)( + "preserves %s behavior for an unknown runtime terminal reason", + (subtype) => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const completionFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ threadId: session.threadId, input: "hello", attachments: [] }); + // An installed CLI can send a terminal reason newer than the bundled SDK. + harness.query.emit({ + type: "result", + subtype, + is_error: subtype !== "success", + result: "", + errors: subtype === "success" ? [] : ["Provider error detail"], + stop_reason: null, + terminal_reason: "future_terminal_reason", + session_id: "sdk-session-future-reason", + uuid: "result-future-reason", + } as unknown as SDKMessage); + const completed = yield* Fiber.join(completionFiber); + assert.equal(completed._tag, "Some"); + if (completed._tag === "Some" && completed.value.type === "turn.completed") { + assert.equal( + completed.value.payload.state, + subtype === "success" ? "completed" : "failed", + ); + assert.equal( + completed.value.payload.errorMessage, + subtype === "success" ? undefined : "Provider error detail", + ); + } + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }, + ); + it.effect("fails a turn when a success result reports a 529 overload", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index a005f583066f..2f0d15e281df 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -434,26 +434,9 @@ function resultErrorsText(result: SDKResultMessage): string { : ""; } -/** - * First user-facing error from a non-success result. "[ede_diagnostic] ..." - * entries are CLI-internal telemetry (the CLI hides them from its own UI too), - * so they must never become the error banner. - */ -function resultUserFacingError(result: SDKResultMessage): string | undefined { - const listed = - result.subtype === "success" || !Array.isArray(result.errors) - ? undefined - : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); - if (listed) { - return listed; - } - // Structured failure markers for results whose error list is empty or - // diagnostic-only: an overloaded API (529) and the terminal reasons the - // CLI stamps when it gives up on a turn. - if (isOverloadedResult(result)) { - return "Claude API is overloaded (529). Try again shortly."; - } - switch (result.terminal_reason) { +/** Failure text for structured terminal reasons, including success-tagged failures. */ +function terminalResultError(reason: SDKResultMessage["terminal_reason"]): string | undefined { + switch (reason) { case "api_error": return "Claude gave up after repeated API errors."; case "malformed_tool_use_exhausted": @@ -1559,27 +1542,6 @@ const buildUserMessageEffect = Effect.fn("buildUserMessageEffect")(function* ( return buildUserMessage({ sdkContent }); }); -/** - * terminal_reason values the CLI classifies as dead turns: the turn died - * rather than finished, even when the result subtype is success and the - * error list is empty. Kept in sync with the messages in - * resultUserFacingError. - */ -const FAILED_TERMINAL_REASONS: ReadonlySet> = - new Set([ - "api_error", - "malformed_tool_use_exhausted", - "budget_exhausted", - "structured_output_retry_exhausted", - "tool_deferred_unavailable", - "turn_setup_failed", - "blocking_limit", - "rapid_refill_breaker", - "prompt_too_long", - "image_error", - "model_error", - ]); - /** * The CLI reports repeated 529 overload failures as a success-subtype result * with api_error_status 529 and an empty error list; the status code is the @@ -1589,25 +1551,27 @@ function isOverloadedResult(result: SDKResultMessage): boolean { return result.subtype === "success" && result.api_error_status === 529; } -function turnStatusFromResult(result: SDKResultMessage): ProviderRuntimeTurnStatus { - if ( - isOverloadedResult(result) || - (result.terminal_reason !== undefined && FAILED_TERMINAL_REASONS.has(result.terminal_reason)) - ) { - return "failed"; - } - if (result.subtype === "success") { - return "completed"; - } - - const errors = resultErrorsText(result); - if (isInterruptedResult(result)) { - return "interrupted"; - } - if (errors.includes("cancel")) { - return "cancelled"; - } - return "failed"; +/** Derives turn status and its error from the same provider result. */ +function resultOutcome(result: SDKResultMessage): { + status: ProviderRuntimeTurnStatus; + errorMessage: string | undefined; +} { + const structuredError = isOverloadedResult(result) + ? "Claude API is overloaded (529). Try again shortly." + : terminalResultError(result.terminal_reason); + // CLI diagnostic entries must not become the error banner. + const listedError = + result.subtype === "success" || !Array.isArray(result.errors) + ? undefined + : result.errors.find((error) => !error.startsWith("[ede_diagnostic]")); + const errorMessage = listedError || structuredError; + if (structuredError !== undefined) return { status: "failed", errorMessage }; + if (result.subtype === "success") return { status: "completed", errorMessage }; + if (isInterruptedResult(result)) return { status: "interrupted", errorMessage }; + return { + status: resultErrorsText(result).includes("cancel") ? "cancelled" : "failed", + errorMessage, + }; } function streamKindFromDeltaType(deltaType: string): ClaudeTextStreamKind { @@ -3268,8 +3232,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( return; } - const status = turnStatusFromResult(message); - const errorMessage = resultUserFacingError(message); + const { status, errorMessage } = resultOutcome(message); if (status === "failed") { yield* emitRuntimeError(context, errorMessage ?? "Claude turn failed."); From f66cfe2219e3f255f573f6430f9c989c58ed517d Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:15:45 -0700 Subject: [PATCH 209/320] fix(server): settle inactive threads without a PR lookup (#10103) --- .../ThreadSettlementReactor.test.ts | 153 ++++++++++++++++-- .../orchestration/ThreadSettlementReactor.ts | 93 ++++++----- 2 files changed, 199 insertions(+), 47 deletions(-) diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index eefc18f7b461..50cabe90c1d3 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -305,7 +305,10 @@ describe("ThreadSettlementReactor", () => { snapshot: makeSnapshot( [ makeThread("inactive", { branch: "inactive-feature" }), - makeThread("closed-pr", { linkedPullRequest }), + makeThread("closed-pr", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), ...skipped, ], [makeProject(), makeProject(LINKED_PROJECT_ID, "/workspace/linked")], @@ -337,7 +340,7 @@ describe("ThreadSettlementReactor", () => { { threadId: ThreadId.make("closed-pr"), snapshotSequence: 1, - settledAt: "2026-08-20T00:00:00.000Z", + settledAt: "2026-08-27T00:00:00.000Z", }, { threadId: ThreadId.make("inactive"), @@ -346,9 +349,7 @@ describe("ThreadSettlementReactor", () => { }, ], ); - assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), [ - { cwd: "/workspace/project", branch: "inactive-feature" }, - ]); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), [ { projectId: LINKED_PROJECT_ID, repository: "owner/repository", number: 42 }, ]); @@ -589,7 +590,12 @@ describe("ThreadSettlementReactor", () => { const releaseLaterLookup = yield* Deferred.make(); const lookupCount = yield* Ref.make(0); const fixture = yield* makeHarness({ - snapshot: makeSnapshot([makeThread("settings-thread", { branch: "saved-feature" })]), + snapshot: makeSnapshot([ + makeThread("settings-thread", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ]), settings: { ...DEFAULT_SERVER_SETTINGS, sidebarAutoSettleAfterDays: null, @@ -661,6 +667,7 @@ describe("ThreadSettlementReactor", () => { snapshot: makeSnapshot( [ makeThread("lookup-failed", { + latestUserMessageAt: "2026-08-27T00:00:00.000Z", linkedPullRequest: { projectId: LINKED_PROJECT_ID, repository: "owner/repository", @@ -695,6 +702,121 @@ describe("ThreadSettlementReactor", () => { ), ); + it.effect("settles inactive linked and branch threads without reading an unavailable host", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("inactive-linked", { + linkedPullRequest: { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }, + }), + makeThread("inactive-branch", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-21T00:00:00.000Z", + }), + ]), + branchPullRequest: () => Effect.die(new Error("host unavailable")), + pullRequestSummary: () => + Effect.fail( + new PullRequestOperationError({ + operation: "summary", + 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(({ threadId, snapshotSequence, settledAt }) => ({ + threadId, + snapshotSequence, + settledAt, + })) + .sort((left, right) => left.threadId.localeCompare(right.threadId)), + [ + { + threadId: ThreadId.make("inactive-branch"), + snapshotSequence: 1, + settledAt: "2026-08-21T00:00:00.000Z", + }, + { + threadId: ThreadId.make("inactive-linked"), + snapshotSequence: 1, + settledAt: "2026-08-20T00:00:00.000Z", + }, + ], + ); + assert.deepStrictEqual(yield* Ref.get(fixture.summaryCalls), []); + assert.deepStrictEqual(yield* Ref.get(fixture.branchCalls), []); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("settles an inactive thread before its shared pull request lookup completes", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const lookupStarted = yield* Deferred.make(); + const releaseLookup = yield* Deferred.make(); + const linkedPullRequest = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + } as const; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot([ + makeThread("recent-linked", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("inactive-linked", { linkedPullRequest }), + ]), + pullRequestSummary: () => + Deferred.succeed(lookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseLookup)), + Effect.andThen( + Effect.fail( + new PullRequestOperationError({ + operation: "summary", + detail: "host unavailable", + }), + ), + ), + ), + }); + + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* reactor.start(); + yield* Deferred.succeed(fixture.activation, undefined); + yield* Deferred.await(lookupStarted); + + assert.deepStrictEqual( + (yield* Ref.get(fixture.commands)).map(({ threadId }) => threadId), + [ThreadId.make("inactive-linked")], + ); + + yield* Deferred.succeed(releaseLookup, undefined); + yield* reactor.drain; + assert.strictEqual((yield* Ref.get(fixture.commands)).length, 1); + assert.strictEqual((yield* Ref.get(fixture.summaryCalls)).length, 1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("keeps threads active when their pull request project is unavailable", () => Effect.scoped( Effect.gen(function* () { @@ -712,7 +834,10 @@ describe("ThreadSettlementReactor", () => { latestUserMessageAt: "2026-08-27T00:00:00.000Z", linkedPullRequest, }), - makeThread("missing-branch-project", { branch: "saved-feature" }), + makeThread("missing-branch-project", { + branch: "saved-feature", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), ], [makeProject(LINKED_PROJECT_ID, "/workspace/linked")], ), @@ -750,13 +875,21 @@ describe("ThreadSettlementReactor", () => { makeThread("branch-one", { branch: "saved-feature", worktreePath: "/deleted/worktree-one", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), makeThread("branch-two", { branch: "saved-feature", worktreePath: "/deleted/worktree-two", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("linked-one", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", + }), + makeThread("linked-two", { + linkedPullRequest, + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), - makeThread("linked-one", { linkedPullRequest }), - makeThread("linked-two", { linkedPullRequest }), ], [ makeProject(PROJECT_ID, "/workspace/project-root"), @@ -803,10 +936,12 @@ describe("ThreadSettlementReactor", () => { makeThread("live-worktree", { branch: "feature/live", worktreePath: "/workspace/project-root/.worktrees/live", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), makeThread("deleted-worktree", { branch: "feature/deleted", worktreePath: "/workspace/project-root/.worktrees/deleted", + latestUserMessageAt: "2026-08-27T00:00:00.000Z", }), ], [makeProject(PROJECT_ID, "/workspace/project-root")], diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 70de3c41d7e9..77a6546365ff 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -51,10 +51,60 @@ export const make = Effect.gen(function* () { // their branch lookup, which would otherwise wait for the next minute's // sweep on a possibly stale cached answer. const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); + + // Return the thread when it still needs a pull request decision. A rejected + // dispatch skips it for this snapshot instead of retrying through a lookup. + const settleThread = Effect.fn("ThreadSettlementReactor.settleThread")( + function* (thread: (typeof candidates)[number], pullRequest: SettlementPullRequest | null) { + const settings = yield* settingsService.getSettings; + const decisionNow = DateTime.formatIso(yield* DateTime.now); + const settledAt = resolveAutoSettlementAt({ + thread, + pullRequest, + now: decisionNow, + autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, + autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, + }); + if (settledAt === null) { + return thread; + } + 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, + settledAt, + }); + return null; + }, + (effect, thread) => + effect.pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("automatic thread settlement skipped", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.as(null)), + ), + ), + ); + + // Inactivity needs no host state. Finish these decisions before any lookup + // can fail or wait on the network, including lookups shared by recent threads. + const lookupCandidates = (yield* Effect.forEach( + candidates, + (thread) => settleThread(thread, null), + { + concurrency: 8, + }, + )).filter((thread) => thread !== null); + // Use the same cwd as the sidebar so both paths share GitManager's PR cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( - candidates, + lookupCandidates, (thread) => Effect.gen(function* () { const project = projects.get(thread.projectId); @@ -99,7 +149,7 @@ export const make = Effect.gen(function* () { cwd === undefined ? ["missing-project", thread.id] : ["branch", cwd, thread.branch], ); }; - const groups = Map.groupBy(candidates, lookupKey); + const groups = Map.groupBy(lookupCandidates, lookupKey); const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( thread: (typeof candidates)[number], @@ -152,42 +202,9 @@ export const make = Effect.gen(function* () { (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); - const settledAt = resolveAutoSettlementAt({ - thread, - pullRequest, - now: decisionNow, - autoSettleAfterDays: settings.sidebarAutoSettleAfterDays, - autoSettleOnMerge: settings.sidebarAutoSettleOnMerge, - }); - if (settledAt === null) { - 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, - settledAt, - }); - }).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 }, - ); + yield* Effect.forEach(group, (thread) => settleThread(thread, pullRequest), { + discard: true, + }); }).pipe( Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) From 60e6fa30c3a8c1e17ba7fb80e5f85f44a0cd2693 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:15:49 -0700 Subject: [PATCH 210/320] fix(ssh): report remote stop failures without losing ownership (#10105) --- packages/ssh/src/runnerProcess.test.ts | 125 +++++++++++- packages/ssh/src/tunnel.test.ts | 254 +++++++++++++++++++------ packages/ssh/src/tunnel.ts | 225 ++++++++++------------ 3 files changed, 427 insertions(+), 177 deletions(-) diff --git a/packages/ssh/src/runnerProcess.test.ts b/packages/ssh/src/runnerProcess.test.ts index d89ee5582c35..82061c0d8c84 100644 --- a/packages/ssh/src/runnerProcess.test.ts +++ b/packages/ssh/src/runnerProcess.test.ts @@ -11,7 +11,7 @@ import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as NodeNet from "node:net"; -import { buildRemoteT3RunnerScript } from "./tunnel.ts"; +import { buildRemoteStopScript, buildRemoteT3RunnerScript } from "./tunnel.ts"; const Started = Schema.Struct({ pid: Schema.Number, @@ -166,6 +166,129 @@ if (args.includes("--package")) { }, ); +describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( + "remote stop process ownership", + () => { + it.live.each(["graceful", "timeout", "external"] as const)( + "confirms the stop result for a %s server", + (mode) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const fixture = yield* fs.makeTempDirectoryScoped({ prefix: "t3-stop-" }); + const signalPath = path.join(fixture, "signals"); + const child = yield* spawner.spawn( + ChildProcess.make( + process.execPath, + [ + "--input-type=module", + "-e", + `import * as fs from "node:fs"; +import * as net from "node:net"; +const server = net.createServer((socket) => socket.end()); +let signals = 0; +process.on("SIGTERM", () => { + fs.writeFileSync(process.argv[2], String(++signals)); + if (process.argv[1] !== "timeout" || signals > 1) server.close(); +}); +server.listen(0, "127.0.0.1", () => { + process.stdout.write(JSON.stringify({ pid: process.pid, port: server.address().port, args: [] }) + "\\n"); +}); +`, + mode, + signalPath, + ], + { cwd: fixture, detached: false }, + ), + ); + // A failed assertion must still stop this captured fixture process. + yield* Effect.addFinalizer(() => + child.kill({ killSignal: "SIGKILL" }).pipe(Effect.ignore), + ); + const started = decodeStarted( + yield* child.stdout.pipe( + Stream.decodeText(), + Stream.splitLines, + Stream.take(1), + Stream.mkString, + ), + ); + assert.equal(started.pid, child.pid); + const savedState = { + pid: `${child.pid}\n`, + port: `${started.port}\n`, + managed: mode === "external" ? "external\n" : "managed\n", + }; + for (const [name, contents] of Object.entries(savedState)) { + yield* fs.writeFileString(path.join(fixture, name), contents); + } + const script = buildRemoteStopScript({ + alias: "fixture", + hostname: "fixture", + username: null, + port: null, + }); + // Redirect only the state directory. Never use the developer's SSH state. + const isolatedScript = script.replace( + /^STATE_DIR=.*$/mu, + 'STATE_DIR="$T3_TEST_STATE_DIR"', + ); + assert.notEqual(isolatedScript, script); + const runStop = Effect.fn("test.remoteStop")(function* () { + const stop = yield* spawner.spawn( + ChildProcess.make("/bin/sh", ["-s"], { + cwd: fixture, + env: { T3_TEST_STATE_DIR: fixture }, + stdin: Stream.make(new TextEncoder().encode(isolatedScript)), + }), + ); + return yield* Effect.all( + { + stdout: stop.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: stop.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: stop.exitCode, + }, + { concurrency: "unbounded" }, + ); + }, Effect.scoped); + let result = yield* runStop(); + if (mode !== "graceful") { + assert.isTrue(yield* child.isRunning); + yield* Effect.callback((resume) => { + const connection = NodeNet.connect(started.port, "127.0.0.1"); + connection.once("error", (error) => resume(Effect.fail(error))); + connection.once("close", () => resume(Effect.void)); + return Effect.sync(() => connection.destroy()); + }); + } + if (mode === "timeout") { + assert.equal(result.exitCode, 1); + assert.equal(result.stdout, ""); + assert.include(result.stderr, "did not stop within 2 seconds"); + assert.equal(yield* fs.readFileString(signalPath), "1"); + for (const [name, contents] of Object.entries(savedState)) { + assert.equal(yield* fs.readFileString(path.join(fixture, name)), contents); + } + result = yield* runStop(); + } + assert.equal(result.exitCode, 0); + assert.equal(result.stdout, '{"stopped":true}\n'); + assert.equal(result.stderr, ""); + for (const name of Object.keys(savedState)) { + assert.isFalse(yield* fs.exists(path.join(fixture, name))); + } + if (mode === "external") { + assert.isFalse(yield* fs.exists(signalPath)); + } else { + assert.equal(yield* child.exitCode, 0); + assert.equal(yield* fs.readFileString(signalPath), mode === "timeout" ? "2" : "1"); + } + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }, +); + describe.skipIf(HostProcessPlatform.defaultValue() === "win32")( "remote runner install diagnostics", () => { diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 4a49cacc2eb8..e2536ba92017 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NetService from "@t3tools/shared/Net"; +import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; @@ -13,6 +14,7 @@ import { HttpClient, HttpClientResponse } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { SshPasswordPrompt } from "./auth.ts"; +import { SshCommandError } from "./errors.ts"; import { buildRemoteLaunchScript, buildRemotePairingScript, @@ -384,63 +386,205 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); - it.effect("closes the tunnel scope and starts fresh after disconnect", () => { - const spawnedCommands: Array> = []; - let tunnelKillCount = 0; - let stopCommandCount = 0; - const spawner = ChildProcessSpawner.make((command) => - Effect.sync(() => { - const args = commandArgs(command); - spawnedCommands.push(args); - if (args.includes("-N")) { - return makeRunningProcess(() => { - tunnelKillCount += 1; - }); - } - if (args.includes("sh") && args.includes("--")) { - return makeSuccessfulProcess('{"remotePort":3773}\n'); - } - if (args.includes("sh")) { - stopCommandCount += 1; - return makeSuccessfulProcess('{"stopped":true}\n'); + it.effect.each(["successful stop", "failed stop"] as const)( + "closes the tunnel scope and starts fresh after a %s", + (mode) => { + const spawnedCommands: Array> = []; + let tunnelKillCount = 0; + let stopCommandCount = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + const args = commandArgs(command); + spawnedCommands.push(args); + if (args.includes("-N")) { + return makeRunningProcess(() => { + tunnelKillCount += 1; + }); + } + if (args.includes("sh") && args.includes("--")) { + return makeSuccessfulProcess('{"remotePort":3773}\n'); + } + if (args.includes("sh")) { + stopCommandCount += 1; + if (mode === "failed stop" && stopCommandCount === 1) { + return { + ...makeSuccessfulProcess(""), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(1)), + stderr: Stream.make( + new TextEncoder().encode("Remote T3 server did not stop within 2 seconds.\n"), + ), + }; + } + return makeSuccessfulProcess('{"stopped":true}\n'); + } + return makeSuccessfulProcess("\n"); + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + const target = { + alias: "devbox", + hostname: "devbox.example.com", + username: "julius", + port: 2222, + } as const; + + return Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + + const first = yield* manager.ensureEnvironment(target); + assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); + const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N")); + assert.isDefined(firstTunnelArgs); + assert.include(firstTunnelArgs, "ControlMaster=no"); + assert.include(firstTunnelArgs, "ControlPath=none"); + assert.include(firstTunnelArgs, "ControlPersist=no"); + + const disconnected = yield* Effect.result(manager.disconnectEnvironment(target)); + if (mode === "failed stop") { + assert.isTrue(Result.isFailure(disconnected)); + if (Result.isFailure(disconnected)) { + assert.instanceOf(disconnected.failure, SshCommandError); + assert.equal( + disconnected.failure.message, + "Remote T3 server did not stop within 2 seconds.", + ); + } + } else { + assert.isTrue(Result.isSuccess(disconnected)); } - return makeSuccessfulProcess("\n"); - }), - ); - const layer = Layer.mergeAll( - NodeServices.layer, - Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), - Layer.succeed(HttpClient.HttpClient, testHttpClient), - Layer.succeed(NetService.NetService, testNetService), - SshPasswordPrompt.disabledLayer, - SshEnvironmentManager.layer(), - ); - const target = { - alias: "devbox", - hostname: "devbox.example.com", - username: "julius", - port: 2222, - } as const; - - return Effect.gen(function* () { - const manager = yield* SshEnvironmentManager; + assert.equal(tunnelKillCount, 1); + assert.equal(stopCommandCount, 1); - const first = yield* manager.ensureEnvironment(target); - assert.equal(first.httpBaseUrl, "http://127.0.0.1:41773/"); - const firstTunnelArgs = spawnedCommands.find((args) => args.includes("-N")); - assert.isDefined(firstTunnelArgs); - assert.include(firstTunnelArgs, "ControlMaster=no"); - assert.include(firstTunnelArgs, "ControlPath=none"); - assert.include(firstTunnelArgs, "ControlPersist=no"); - - yield* manager.disconnectEnvironment(target); - assert.equal(tunnelKillCount, 1); - assert.equal(stopCommandCount, 1); + if (mode === "failed stop") { + yield* manager.disconnectEnvironment(target); + assert.equal(tunnelKillCount, 1); + assert.equal(stopCommandCount, 2); + } - yield* manager.ensureEnvironment(target); + yield* manager.ensureEnvironment(target); + + assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2); + assert.equal(tunnelKillCount, 1); + }).pipe( + Effect.provide(layer), + Effect.scoped, + Effect.andThen( + Effect.sync(() => { + assert.equal(tunnelKillCount, 2); + assert.equal(stopCommandCount, mode === "failed stop" ? 3 : 2); + }), + ), + ); + }, + ); - assert.equal(spawnedCommands.filter((args) => args.includes("-N")).length, 2); - assert.equal(tunnelKillCount, 1); - }).pipe(Effect.provide(layer), Effect.scoped); - }); + it.effect.each(["local tunnel", "remote server"] as const)( + "waits for %s shutdown before reconnecting the same target", + (stalledStep) => + Effect.gen(function* () { + const shutdownStarted = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + const reconnectsStarted = yield* Deferred.make(); + const pauseShutdown = Deferred.succeed(shutdownStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishShutdown)), + ); + let resolutions = 0; + let launches = 0; + let tunnels = 0; + let stops = 0; + let remoteRunning = false; + const target = { alias: "devbox", hostname: "devbox", username: null, port: null }; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = commandArgs(command); + const isTarget = args.includes(target.alias); + if (args.includes("-G")) { + if (isTarget && ++resolutions === 4) { + yield* Deferred.succeed(reconnectsStarted, undefined); + } + return makeSuccessfulProcess(""); + } + if (args.includes("-N")) { + const tunnel = makeRunningProcess(() => undefined); + if (isTarget && ++tunnels === 1 && stalledStep === "local tunnel") { + return { + ...tunnel, + kill: (options?: ChildProcess.KillOptions) => + pauseShutdown.pipe(Effect.andThen(tunnel.kill(options))), + }; + } + return tunnel; + } + if (args.includes("--")) { + if (isTarget) { + launches += 1; + remoteRunning = true; + } + return makeSuccessfulProcess('{"remotePort":3773}\n'); + } + const stop = makeSuccessfulProcess('{"stopped":true}\n'); + if (!isTarget) return stop; + const pause = ++stops === 1 && stalledStep === "remote server"; + return { + ...stop, + exitCode: (pause ? pauseShutdown : Effect.void).pipe( + Effect.andThen( + Effect.sync(() => { + remoteRunning = false; + return ChildProcessSpawner.ExitCode(0); + }), + ), + ), + }; + }), + ); + const layer = Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshPasswordPrompt.disabledLayer, + SshEnvironmentManager.layer(), + ); + yield* Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + yield* manager.ensureEnvironment(target); + const disconnect = yield* Effect.forkChild(manager.disconnectEnvironment(target)); + yield* Deferred.await(shutdownStarted); + const firstReconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); + const secondReconnect = yield* Effect.forkChild(manager.ensureEnvironment(target)); + yield* Deferred.await(reconnectsStarted); + + yield* manager.ensureEnvironment({ + alias: "other", + hostname: "other", + username: null, + port: null, + }); + yield* TestClock.adjust(Duration.zero); + const launchesBeforeShutdown = launches; + yield* Deferred.succeed(finishShutdown, undefined); + yield* Fiber.join(disconnect); + const first = yield* Fiber.join(firstReconnect); + const second = yield* Fiber.join(secondReconnect); + + assert.equal(launchesBeforeShutdown, 1); + assert.equal(launches, 2); + assert.equal(tunnels, 2); + assert.isTrue(remoteRunning); + assert.equal(first.httpBaseUrl, second.httpBaseUrl); + }).pipe( + Effect.ensuring(Deferred.succeed(finishShutdown, undefined)), + Effect.provide(layer), + Effect.scoped, + ); + }), + ); }); diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index a5bc55a7f778..9cb6b25e4121 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -10,7 +10,6 @@ import * as NetService from "@t3tools/shared/Net"; import { extractJsonObject, fromLenientJson } from "@t3tools/shared/schemaJson"; import { satisfiesSemverRange } from "@t3tools/shared/semver"; import * as Context from "effect/Context"; -import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -18,6 +17,7 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/unstable/http"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; @@ -98,15 +98,6 @@ type SshEnvironmentEffectError = | SshPasswordPromptError | NetService.NetError; -function makeSshTunnelCancelledError(target: DesktopSshEnvironmentTarget): SshCommandError { - return new SshCommandError({ - command: ["ssh"], - exitCode: null, - stderr: "", - message: `SSH environment connection was cancelled for ${target.alias || target.hostname}.`, - }); -} - function sshTargetLogFields(target: DesktopSshEnvironmentTarget) { return { alias: target.alias, @@ -642,6 +633,10 @@ if [ "$REMOTE_MANAGED" != "external" ] && [ -n "$REMOTE_PID" ] && kill -0 "$REMO WAIT_COUNT=$((WAIT_COUNT + 1)) sleep 0.1 done + if kill -0 "$REMOTE_PID" 2>/dev/null; then + printf 'Remote T3 server with PID %s did not stop within 2 seconds. Its ownership files were kept.\\n' "$REMOTE_PID" >&2 + exit 1 + fi fi rm -f "$PID_FILE" "$PORT_FILE" "$MANAGED_FILE" printf '{"stopped":true}\\n' @@ -1176,12 +1171,22 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ): Effect.fn.Return { const managerScope = yield* Scope.Scope; const tunnels = new Map(); - const pendingTunnelEntries = new Map< - string, - Deferred.Deferred - >(); + const targetLocks = new Map(); const authSecrets = new Map(); + // Keep one lock per target so reconnect cannot reuse a server while stop is pending. + const withTargetLock = Effect.fn("ssh/tunnel.withTargetLock")(function* ( + key: string, + effect: Effect.Effect, + ): Effect.fn.Return { + let lock = targetLocks.get(key); + if (lock === undefined) { + lock = Semaphore.makeUnsafe(1); + targetLocks.set(key, lock); + } + return yield* lock.withPermits(1)(effect); + }); + const closeTunnelEntry = Effect.fn("ssh/tunnel.closeTunnelEntry")(function* ( entry: SshTunnelEntry, ) { @@ -1200,18 +1205,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma }); }); - const cancelPendingTunnelEntry = Effect.fn("ssh/tunnel.cancelPendingTunnelEntry")(function* ( - key: string, - target: DesktopSshEnvironmentTarget, - ) { - const pending = pendingTunnelEntries.get(key); - if (!pending) { - return; - } - pendingTunnelEntries.delete(key); - yield* Deferred.fail(pending, makeSshTunnelCancelledError(target)).pipe(Effect.ignore); - }); - yield* Scope.addFinalizer( managerScope, Effect.sync(() => [...tunnels.values()]).pipe( @@ -1392,7 +1385,17 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma yield* Scope.addFinalizer( entryScope, Effect.gen(function* () { - if (tunnels.get(tunnelEntry.key) !== tunnelEntry) { + const stopRemote = tunnels.get(tunnelEntry.key) === tunnelEntry; + if (stopRemote) { + tunnels.delete(tunnelEntry.key); + } + yield* tunnelEntry.process + .kill({ + killSignal: "SIGTERM", + forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, + }) + .pipe(Effect.ignore); + if (!stopRemote) { return; } yield* Effect.logDebug("ssh.environment.tunnel.finalizer.start", { @@ -1401,34 +1404,24 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma localPort: tunnelEntry.localPort, remotePort: tunnelEntry.remotePort, }); - tunnels.delete(tunnelEntry.key); const authSecret = authSecrets.get(tunnelEntry.key) ?? null; - yield* Effect.all( - [ - tunnelEntry.process.kill({ - killSignal: "SIGTERM", - forceKillAfter: TUNNEL_SHUTDOWN_TIMEOUT_MS, - }), - stopRemoteServer( - tunnelEntry.target, - authSecret === null - ? { - batchMode: "yes", - interactiveAuth: false, - } - : { - authSecret, - batchMode: "no", - interactiveAuth: true, - }, - ).pipe( - Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), - Effect.provideService(FileSystem.FileSystem, fileSystemService), - Effect.provideService(Path.Path, pathService), - ), - ], - { concurrency: "unbounded" }, - ).pipe(Effect.ignore); + yield* stopRemoteServer( + tunnelEntry.target, + authSecret === null + ? { + batchMode: "yes", + interactiveAuth: false, + } + : { + authSecret, + batchMode: "no", + interactiveAuth: true, + }, + ).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawnerService), + Effect.provideService(FileSystem.FileSystem, fileSystemService), + Effect.provideService(Path.Path, pathService), + ); yield* Effect.logDebug("ssh.environment.tunnel.finalizer.succeeded", { ...sshTargetLogFields(tunnelEntry.target), key: tunnelEntry.key, @@ -1451,7 +1444,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma resolvedTarget: DesktopSshEnvironmentTarget, runner?: RemoteT3RunnerOptions, ): Effect.fn.Return { - let entry = tunnels.get(key) ?? null; + const entry = tunnels.get(key) ?? null; if (entry !== null) { yield* Effect.logDebug("ssh.environment.tunnel.existing.check", { @@ -1480,22 +1473,8 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma cause: readinessExit.cause, }); yield* closeTunnelEntry(entry); - yield* cancelPendingTunnelEntry(key, resolvedTarget); - entry = null; } - const pending = pendingTunnelEntries.get(key); - if (pending) { - yield* Effect.logDebug("ssh.environment.tunnel.pending.await", { - ...sshTargetLogFields(resolvedTarget), - key, - }); - return yield* Deferred.await(pending); - } - - const deferred = yield* Deferred.make(); - pendingTunnelEntries.set(key, deferred); - return yield* createTunnelEntry({ key, resolvedTarget, @@ -1508,13 +1487,6 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma cause, }), ), - Effect.onExit((exit) => - Effect.sync(() => { - if (pendingTunnelEntries.get(key) === deferred) { - pendingTunnelEntries.delete(key); - } - }).pipe(Effect.andThen(Deferred.done(deferred, exit))), - ), ); }); @@ -1553,33 +1525,39 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...sshRunnerLogFields(runner), key, }); - const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); + return yield* withTargetLock( + key, + Effect.gen(function* () { + const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); + + const pairingResult = requestOptions?.issuePairingToken + ? yield* runWithSshAuth({ + key, + target: entry.target, + operation: (authOptions) => + issueRemotePairingToken(entry.target, authOptions, runner), + }) + : null; + const pairingToken = pairingResult?.credential ?? null; - const pairingResult = requestOptions?.issuePairingToken - ? yield* runWithSshAuth({ + yield* Effect.logInfo("ssh.environment.ensure.succeeded", { + ...sshTargetLogFields(entry.target), key, + localPort: entry.localPort, + remotePort: entry.remotePort, + remoteServerKind: entry.remoteServerKind, + issuedPairingToken: pairingToken !== null, + }); + return { target: entry.target, - operation: (authOptions) => issueRemotePairingToken(entry.target, authOptions, runner), - }) - : null; - const pairingToken = pairingResult?.credential ?? null; - - yield* Effect.logInfo("ssh.environment.ensure.succeeded", { - ...sshTargetLogFields(entry.target), - key, - localPort: entry.localPort, - remotePort: entry.remotePort, - remoteServerKind: entry.remoteServerKind, - issuedPairingToken: pairingToken !== null, - }); - return { - target: entry.target, - httpBaseUrl: entry.httpBaseUrl, - wsBaseUrl: entry.wsBaseUrl, - pairingToken, - remotePort: entry.remotePort, - ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}), - }; + httpBaseUrl: entry.httpBaseUrl, + wsBaseUrl: entry.wsBaseUrl, + pairingToken, + remotePort: entry.remotePort, + ...(entry.remoteServerKind ? { remoteServerKind: entry.remoteServerKind } : {}), + }; + }), + ); }); const disconnectEnvironment = Effect.fn("ssh/tunnel.disconnectEnvironment")(function* ( @@ -1593,28 +1571,33 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma ...(target.port !== null ? { port: target.port } : {}), }; const key = targetConnectionKey(resolvedTarget); - const entry = tunnels.get(key) ?? null; - yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", { - ...sshTargetLogFields(resolvedTarget), - key, - hasTunnel: entry !== null, - hasPendingTunnel: pendingTunnelEntries.has(key), - }); - if (entry !== null) { - yield* closeTunnelEntry(entry); - } - yield* cancelPendingTunnelEntry(key, resolvedTarget); - if (entry === null) { - yield* runWithSshAuth({ - key, - target: resolvedTarget, - operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions), - }); - } - yield* Effect.logInfo("ssh.environment.disconnect.succeeded", { - ...sshTargetLogFields(resolvedTarget), + yield* withTargetLock( key, - }); + Effect.gen(function* () { + const entry = tunnels.get(key) ?? null; + yield* Effect.logDebug("ssh.environment.disconnect.targetResolved", { + ...sshTargetLogFields(resolvedTarget), + key, + hasTunnel: entry !== null, + }); + if (entry !== null) { + // Explicit disconnect owns the remote stop so its failure reaches the caller. + yield* Effect.gen(function* () { + tunnels.delete(key); + yield* closeTunnelEntry(entry); + }).pipe(Effect.uninterruptible); + } + yield* runWithSshAuth({ + key, + target: resolvedTarget, + operation: (authOptions) => stopRemoteServer(resolvedTarget, authOptions), + }); + yield* Effect.logInfo("ssh.environment.disconnect.succeeded", { + ...sshTargetLogFields(resolvedTarget), + key, + }); + }), + ); }); return SshEnvironmentManager.of({ ensureEnvironment, disconnectEnvironment }); From 281b92b48810062275798dd4107364384ebaf432 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:15:53 -0700 Subject: [PATCH 211/320] perf(server): stop scanning old OpenCode parts (#10116) --- .../provider/Layers/OpenCodeAdapter.test.ts | 309 +++++++++++++++++- .../src/provider/Layers/OpenCodeAdapter.ts | 183 ++++++----- 2 files changed, 411 insertions(+), 81 deletions(-) diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 8fa4de72f6c5..c8a7d12af276 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { beforeEach } from "vite-plus/test"; +import { beforeEach, vi } from "vite-plus/test"; import type { Event as OpenCodeEvent, PermissionRequest, @@ -578,6 +578,18 @@ function promiseWithResolvers() { return { promise, resolve, reject }; } +function makeOpenCodeEventQueue() { + let pending = promiseWithResolvers(); + const events = [pending.promise]; + runtimeMock.state.subscribedEvents = events; + return (event: unknown) => { + const current = pending; + pending = promiseWithResolvers(); + events.push(pending.promise); + current.resolve(event); + }; +} + const permissionRequest = (id: string, sessionID: string): PermissionRequest => ({ id, sessionID, @@ -6708,6 +6720,301 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("processes late assistant metadata without visiting completed turns", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-indexed-opencode-parts"); + const sessionID = "http://127.0.0.1:9999/session"; + const enqueue = makeOpenCodeEventQueue(); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + for (let index = 0; index < 24; index += 1) { + const completed = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "turn.completed"), + Stream.runHead, + Effect.forkChild, + ); + yield* adapter.sendTurn({ + threadId, + input: `Complete turn ${index}`, + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + enqueue({ + type: "message.updated", + properties: { sessionID, info: { id: `history-message-${index}`, role: "assistant" } }, + }); + enqueue({ + type: "message.part.updated", + properties: { + sessionID, + part: { + id: `history-part-${index}`, + messageID: `history-message-${index}`, + sessionID, + type: "text", + text: `Completed turn ${index}`, + time: { start: 1, end: 2 }, + }, + }, + }); + enqueue({ + type: "session.status", + properties: { sessionID, status: { type: "idle" } }, + }); + yield* Fiber.join(completed); + } + + let visitedHistoryParts = 0; + const values = Map.prototype.values; + yield* Effect.acquireRelease( + Effect.sync(() => + vi + .spyOn(Map.prototype, "values") + .mockImplementation(function (this: Map) { + const iterator = values.call(this); + const next = iterator.next.bind(iterator); + iterator.next = () => { + const result = next(); + const value: unknown = result.value; + if ( + typeof value === "object" && + value !== null && + "id" in value && + typeof value.id === "string" && + value.id.startsWith("history-part-") + ) { + visitedHistoryParts += 1; + } + return result; + }; + return iterator; + }), + ), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + + const stepProcessed = yield* Deferred.make(); + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.tap((event) => + event.type === "thread.state.changed" + ? Deferred.succeed(stepProcessed, undefined) + : Effect.void, + ), + Stream.takeUntil((event) => event.type === "turn.completed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.sendTurn({ + threadId, + input: "Process late metadata", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + const promptMessageId = (runtimeMock.state.promptCalls.at(-1) as { messageID: string }) + .messageID; + const part = { + id: "current-part", + messageID: "current-message", + sessionID, + type: "text", + text: "Current response", + time: { start: 3, end: 4 }, + }; + const step = { + id: "current-step", + messageID: "current-message", + sessionID, + type: "step-finish", + reason: "stop", + cost: 0, + tokens: { input: 40, output: 10, reasoning: 2, cache: { read: 5, write: 1 } }, + }; + enqueue({ type: "message.part.updated", properties: { sessionID, part } }); + enqueue({ type: "message.part.updated", properties: { sessionID, part: step } }); + enqueue({ type: "session.compacted", properties: { sessionID } }); + yield* Deferred.await(stepProcessed); + yield* adapter.sendTurn({ + threadId, + input: "Steer before metadata arrives", + modelSelection: createModelSelection( + ProviderInstanceId.make("opencode"), + "opencode/kimi-k3", + ), + }); + for (const parentID of ["", promptMessageId]) { + enqueue({ + type: "message.updated", + properties: { sessionID, info: { id: "current-message", role: "assistant", parentID } }, + }); + } + enqueue({ type: "message.part.updated", properties: { sessionID, part } }); + enqueue({ type: "message.part.updated", properties: { sessionID, part: step } }); + enqueue({ + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "history-part-0", + messageID: "history-message-0", + sessionID, + type: "text", + text: "Completed turn zero", + time: { start: 1, end: 2 }, + }, + }, + }); + enqueue({ type: "session.status", properties: { sessionID, status: { type: "idle" } } }); + + const events = yield* Fiber.join(eventsFiber); + NodeAssert.equal(visitedHistoryParts, 0); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => event.payload.delta), + ["Current response", "zero"], + ); + NodeAssert.equal(events.filter((event) => event.type === "item.completed").length, 1); + const completed = events.find((event) => event.type === "turn.completed"); + NodeAssert.deepEqual(completed?.payload.tokenUsage, { + usageStatus: "complete", + usageScope: "main_agent", + inputTokens: 46, + cachedInputTokens: 5, + cacheCreationTokens: 1, + outputTokens: 12, + reasoningTokens: 2, + hasSubagents: false, + }); + yield* adapter.stopSession(threadId); + }).pipe(Effect.scoped), + ); + + it.effect("keeps completed text edits and clears removed parts across reconnects", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-text-retention"); + const sessionID = "http://127.0.0.1:9999/session"; + const messageID = "retained-message"; + const metadata = { + type: "message.updated", + properties: { + sessionID, + info: { id: messageID, role: "assistant", time: { created: 1, completed: 2 } }, + }, + }; + const snapshot = ( + text: string, + id = "retained-part", + type: "text" | "reasoning" = "text", + ) => ({ + type: "message.part.updated", + properties: { + sessionID, + part: { id, sessionID, messageID, type, text, time: { start: 1, end: 2 } }, + }, + }); + const delta = (text: string) => ({ + type: "message.part.delta", + properties: { sessionID, messageID, partID: "retained-part", field: "text", delta: text }, + }); + const nonTextReplacement = { + type: "message.part.updated", + properties: { + sessionID, + part: { + id: "retained-part", + sessionID, + messageID, + type: "file", + mime: "text/plain", + url: "file:///repo/result.txt", + }, + }, + }; + runtimeMock.state.subscribedEvents = [ + snapshot("Replaced before metadata"), + nonTextReplacement, + metadata, + snapshot("Thinking", "reasoning-part", "reasoning"), + snapshot("Hello world"), + { type: "server.connected", properties: {} }, + metadata, + snapshot("Thinking", "reasoning-part", "reasoning"), + snapshot("Thinking more", "reasoning-part", "reasoning"), + snapshot("Hello world"), + snapshot("Hello"), + snapshot("Hello there"), + delta(" again"), + snapshot("Hello there again"), + nonTextReplacement, + delta("ignored while file"), + metadata, + snapshot("Hello there again!"), + { + type: "message.part.removed", + properties: { sessionID, messageID, partID: "retained-part" }, + }, + delta("removed part"), + metadata, + snapshot("Fresh"), + snapshot("Second", "second-part"), + { type: "message.removed", properties: { sessionID, messageID } }, + delta("removed message"), + metadata, + snapshot("New thoughts", "reasoning-part", "reasoning"), + snapshot("New"), + { type: "session.compacted", properties: { sessionID } }, + ]; + const eventsFiber = yield* adapter.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId), + Stream.takeUntil((event) => event.type === "thread.state.changed"), + Stream.runCollect, + Effect.forkChild, + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + }); + + const events = yield* Fiber.join(eventsFiber); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "content.delta") + .map((event) => [event.payload.streamKind, event.payload.delta]), + [ + ["reasoning_text", "Thinking"], + ["assistant_text", "Hello world"], + ["reasoning_text", " more"], + ["assistant_text", "there"], + ["assistant_text", " again"], + ["assistant_text", "!"], + ["assistant_text", "Fresh"], + ["assistant_text", "Second"], + ["reasoning_text", "New thoughts"], + ["assistant_text", "New"], + ], + ); + NodeAssert.deepEqual( + events + .filter((event) => event.type === "item.completed") + .map((event) => event.payload.detail), + ["Hello world", "Fresh", "Second", "New"], + ); + yield* adapter.stopSession(threadId); + }), + ); + it.effect("maps native task progress only while a turn is active", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index b10a0c14b2b6..38444be4ad19 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -322,6 +322,16 @@ function isOpenCodeDefaultTitle(title: string): boolean { return OPENCODE_DEFAULT_TITLE_PATTERN.test(title); } +type OpenCodeTextPart = Extract; + +type OpenCodeTextPartState = Pick & { + text: string | undefined; + emittedText: string | undefined; + completed: boolean; +}; + +type OpenCodeStepUsage = Pick, "id" | "tokens">; + interface OpenCodeSessionContext { session: ProviderSession; readonly client: OpencodeClient; @@ -336,9 +346,9 @@ interface OpenCodeSessionContext { readonly pendingPermissions: Map; readonly pendingQuestions: Map; readonly messageRoleById: Map; - readonly partById: Map; - readonly emittedTextByPartId: Map; - readonly completedAssistantPartIds: Set; + // OpenCode permits edits to completed parts. Keep text for snapshot comparison + // until native removal or session teardown, but do not retain other part payloads. + readonly textPartsByMessageId: Map>; turnTokenUsage: OpenCodeTurnTokenUsageAccumulator | undefined; activeTurnId: TurnId | undefined; activeAgent: string | undefined; @@ -374,7 +384,8 @@ interface OpenCodeTurnTokenUsageAccumulator { readonly partIds: Set; readonly promptMessageIds: Set; readonly assistantOwnershipByMessageId: Map; - readonly unresolvedStepPartIds: Set; + // Native removal does not undo usage. Keep unresolved counts until this turn settles. + readonly unresolvedStepsByMessageId: Map>; inputTokens: number; cachedInputTokens: number; cacheCreationTokens: number; @@ -389,7 +400,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula partIds: new Set(), promptMessageIds: new Set(), assistantOwnershipByMessageId: new Map(), - unresolvedStepPartIds: new Set(), + unresolvedStepsByMessageId: new Map(), inputTokens: 0, cachedInputTokens: 0, cacheCreationTokens: 0, @@ -402,7 +413,7 @@ function makeOpenCodeTurnTokenUsageAccumulator(): OpenCodeTurnTokenUsageAccumula function accumulateOpenCodeStepUsage( accumulator: OpenCodeTurnTokenUsageAccumulator, - part: Extract, + part: OpenCodeStepUsage, ): void { if (accumulator.partIds.has(part.id)) return; accumulator.partIds.add(part.id); @@ -428,7 +439,9 @@ function takeOpenCodeTurnTokenUsage( } return { usageStatus: - complete && usage.complete && usage.unresolvedStepPartIds.size === 0 ? "complete" : "partial", + complete && usage.complete && usage.unresolvedStepsByMessageId.size === 0 + ? "complete" + : "partial", usageScope: "main_agent", inputTokens: usage.inputTokens, cachedInputTokens: usage.cachedInputTokens, @@ -579,18 +592,29 @@ function normalizeQuestionRequest(request: QuestionRequest): ReadonlyArray): "assistant_text" | "reasoning_text" { + return part.type === "reasoning" ? "reasoning_text" : "assistant_text"; } -function textFromPart(part: Part): string | undefined { - switch (part.type) { - case "text": - case "reasoning": - return part.text; - default: - return undefined; - } +function retainOpenCodeTextPart( + context: OpenCodeSessionContext, + part: OpenCodeTextPart, +): OpenCodeTextPartState { + const parts = + context.textPartsByMessageId.get(part.messageID) ?? new Map(); + const previous = parts.get(part.id); + const state = { + id: part.id, + messageID: part.messageID, + type: part.type, + text: part.text, + ...(part.time !== undefined ? { time: part.time } : {}), + emittedText: previous?.emittedText, + completed: previous?.completed ?? false, + }; + parts.set(part.id, state); + context.textPartsByMessageId.set(part.messageID, parts); + return state; } function commonPrefixLength(left: string, right: string): number { @@ -1362,6 +1386,7 @@ export function makeOpenCodeAdapter( if (message?.info.id === promptAdmission.messageId && message.info.role === "user") { promptAdmission.messageObserved = true; context.messageRoleById.set(promptAdmission.messageId, "user"); + context.textPartsByMessageId.delete(promptAdmission.messageId); } } @@ -1568,35 +1593,23 @@ export function makeOpenCodeAdapter( /** Emit content.delta and item.completed events for an assistant text part. */ const emitAssistantTextDelta = Effect.fn("emitAssistantTextDelta")(function* ( context: OpenCodeSessionContext, - part: Part, + part: OpenCodeTextPartState, turnId: TurnId | undefined, raw: unknown, ) { - const text = textFromPart(part); - if (text === undefined) { + if (part.text === undefined) { return; } - const previousText = context.emittedTextByPartId.get(part.id); - const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(previousText, text); - context.emittedTextByPartId.set(part.id, latestText); - if (latestText !== text) { - context.partById.set( - part.id, - (part.type === "text" || part.type === "reasoning" - ? { ...part, text: latestText } - : part) satisfies Part, - ); - } + const { latestText, deltaToEmit } = mergeOpenCodeAssistantText(part.emittedText, part.text); + part.emittedText = latestText; + part.text = latestText; if (deltaToEmit.length > 0) { yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, turnId, itemId: part.id, - createdAt: - (part.type === "text" || part.type === "reasoning") && part.time !== undefined - ? isoFromEpochMs(part.time.start) - : undefined, + createdAt: part.time !== undefined ? isoFromEpochMs(part.time.start) : undefined, raw, })), type: "content.delta", @@ -1607,12 +1620,8 @@ export function makeOpenCodeAdapter( }); } - if ( - part.type === "text" && - part.time?.end !== undefined && - !context.completedAssistantPartIds.has(part.id) - ) { - context.completedAssistantPartIds.add(part.id); + if (part.type === "text" && part.time?.end !== undefined && !part.completed) { + part.completed = true; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -2315,6 +2324,9 @@ export function makeOpenCodeAdapter( } } context.messageRoleById.set(event.properties.info.id, event.properties.info.role); + if (event.properties.info.role === "user") { + context.textPartsByMessageId.delete(event.properties.info.id); + } if (event.properties.info.role === "assistant") { const usage = context.turnTokenUsage; const parentMessageId = @@ -2337,15 +2349,19 @@ export function makeOpenCodeAdapter( : priorOwnership; if (usage) { usage.assistantOwnershipByMessageId.set(event.properties.info.id, ownership); - } - for (const part of context.partById.values()) { - if (part.messageID !== event.properties.info.id) { - continue; - } - if (usage && part.type === "step-finish") { - if (ownership !== "unknown") usage.unresolvedStepPartIds.delete(part.id); - if (ownership === "owned") accumulateOpenCodeStepUsage(usage, part); + if (ownership !== "unknown") { + const steps = usage.unresolvedStepsByMessageId.get(event.properties.info.id); + if (ownership === "owned" && steps) { + for (const step of steps.values()) { + accumulateOpenCodeStepUsage(usage, step); + } + } + usage.unresolvedStepsByMessageId.delete(event.properties.info.id); } + } + for (const part of context.textPartsByMessageId + .get(event.properties.info.id) + ?.values() ?? []) { yield* emitAssistantTextDelta(context, part, turnId, event); } } @@ -2354,16 +2370,24 @@ export function makeOpenCodeAdapter( case "message.removed": { context.messageRoleById.delete(event.properties.messageID); + context.textPartsByMessageId.delete(event.properties.messageID); + break; + } + + case "message.part.removed": { + const parts = context.textPartsByMessageId.get(event.properties.messageID); + parts?.delete(event.properties.partID); + if (parts?.size === 0) { + context.textPartsByMessageId.delete(event.properties.messageID); + } break; } case "message.part.delta": { - const existingPart = context.partById.get(event.properties.partID); - if ( - !existingPart || - (existingPart.type !== "text" && existingPart.type !== "reasoning") || - event.properties.field !== "text" - ) { + const existingPart = context.textPartsByMessageId + .get(event.properties.messageID) + ?.get(event.properties.partID); + if (existingPart?.text === undefined || event.properties.field !== "text") { break; } const role = messageRoleForPart(context, existingPart); @@ -2375,21 +2399,13 @@ export function makeOpenCodeAdapter( if (delta.length === 0) { break; } - const previousText = - context.emittedTextByPartId.get(event.properties.partID) ?? - textFromPart(existingPart) ?? - ""; + const previousText = existingPart.emittedText ?? existingPart.text; const { nextText, deltaToEmit } = appendOpenCodeAssistantTextDelta(previousText, delta); if (deltaToEmit.length === 0) { break; } - context.emittedTextByPartId.set(event.properties.partID, nextText); - if (existingPart.type === "text" || existingPart.type === "reasoning") { - context.partById.set(event.properties.partID, { - ...existingPart, - text: nextText, - }); - } + existingPart.emittedText = nextText; + existingPart.text = nextText; yield* emit({ ...(yield* buildEventBase({ threadId: context.session.threadId, @@ -2408,29 +2424,38 @@ export function makeOpenCodeAdapter( case "message.part.updated": { const part = event.properties.part; - // Tool events use the incoming part and do not need a cached copy. - if (part.type !== "tool") { - context.partById.set(part.id, part); - } const messageRole = messageRoleForPart(context, part); if (turnId && part.type === "step-finish" && context.turnTokenUsage) { - const ownership = context.turnTokenUsage.assistantOwnershipByMessageId.get( - part.messageID, - ); + const usage = context.turnTokenUsage; + const ownership = usage.assistantOwnershipByMessageId.get(part.messageID); if (ownership === "owned") { - accumulateOpenCodeStepUsage(context.turnTokenUsage, part); + accumulateOpenCodeStepUsage(usage, part); } else if ( ownership === "unknown" || (ownership === undefined && context.messageRoleById.get(part.messageID) !== "assistant") ) { - context.turnTokenUsage.unresolvedStepPartIds.add(part.id); + const steps = + usage.unresolvedStepsByMessageId.get(part.messageID) ?? + new Map(); + steps.set(part.id, { id: part.id, tokens: part.tokens }); + usage.unresolvedStepsByMessageId.set(part.messageID, steps); } } - if (messageRole === "assistant") { - yield* emitAssistantTextDelta(context, part, turnId, event); + if ((part.type === "text" || part.type === "reasoning") && messageRole !== "user") { + const state = retainOpenCodeTextPart(context, part); + if (messageRole === "assistant") { + yield* emitAssistantTextDelta(context, state, turnId, event); + } + } else { + const previous = context.textPartsByMessageId.get(part.messageID)?.get(part.id); + if (previous) { + // A non-text PATCH removes the current snapshot. Keep emitted text + // so a later text PATCH still emits only the changed suffix. + previous.text = undefined; + } } if (part.type === "tool") { @@ -2963,10 +2988,8 @@ export function makeOpenCodeAdapter( requestRelationRetries: new Map(), pendingPermissions: new Map(), pendingQuestions: new Map(), - partById: new Map(), - emittedTextByPartId: new Map(), + textPartsByMessageId: new Map(), messageRoleById: new Map(), - completedAssistantPartIds: new Set(), turnTokenUsage: undefined, activeTurnId: undefined, activeAgent: undefined, From 17490c0a00a6cf9a542fc8fa5d85f489fa662e70 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:17:16 -0700 Subject: [PATCH 212/320] perf(server): avoid full thread reads on turn start (#10108) --- .../checkpointing/CheckpointDiffQuery.test.ts | 5 + .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionSnapshotQuery.test.ts | 141 ++++++++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 68 +++++++++ .../Layers/ProviderCommandReactor.test.ts | 42 ++++++ .../Layers/ProviderCommandReactor.ts | 17 ++- .../Services/ProjectionSnapshotQuery.ts | 17 +++ .../src/project/AgentSessionScanner.test.ts | 1 + .../project/ProjectSetupScriptRunner.test.ts | 1 + .../provider/Layers/ProviderService.test.ts | 1 + .../Layers/ProviderSessionReaper.test.ts | 1 + apps/server/src/serverRuntimeStartup.test.ts | 4 + 12 files changed, 291 insertions(+), 8 deletions(-) diff --git a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts index d05ca5ec854a..c9f514037804 100644 --- a/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/CheckpointDiffQuery.test.ts @@ -109,6 +109,7 @@ describe("CheckpointDiffQuery.layer", () => { }); }), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -207,6 +208,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -295,6 +297,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -368,6 +371,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), @@ -426,6 +430,7 @@ describe("CheckpointDiffQuery.layer", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index abfb49b53050..2c426c5ee5d0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -422,6 +422,7 @@ describe("OrchestrationEngine", () => { getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.succeed(Option.none()), getThreadDetailById: () => Effect.succeed(Option.none()), getThreadDetailSnapshot: () => Effect.succeed(Option.none()), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index a1b351de3535..2e0a3c3459a5 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -1,5 +1,6 @@ import { type AgentSessionImportSource, + ChatAttachment, CheckpointRef, EventId, MessageId, @@ -12,6 +13,7 @@ import { assert, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; @@ -31,6 +33,9 @@ const asTurnId = (value: string): TurnId => TurnId.make(value); const asMessageId = (value: string): MessageId => MessageId.make(value); const asEventId = (value: string): EventId => EventId.make(value); const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value); +const encodeChatAttachments = Schema.encodeEffect( + Schema.fromJsonString(Schema.Array(ChatAttachment)), +); const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( @@ -598,6 +603,142 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { }), ); + it.effect("reads one turn-start message without decoding unrelated history", () => + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-turn-start-read"); + const messageId = MessageId.make("message-turn-start-read"); + const createdAt = "2026-09-05T00:00:00.000Z"; + const attachments = [ + { + type: "file" as const, + id: "notes", + name: "notes.txt", + mimeType: "text/plain", + sizeBytes: 8, + }, + ]; + const attachmentsJson = yield* encodeChatAttachments(attachments); + yield* sql` + WITH RECURSIVE history(n) AS ( + VALUES (1) UNION ALL SELECT n + 1 FROM history WHERE n < 2000 + ) + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, attachments_json, + is_streaming, created_at, updated_at + ) + SELECT 'turn-start-history:' || n, ${threadId}, 'old-turn:' || n, 'assistant', + 'Unrelated assistant output', 'not-json', 0, ${createdAt}, ${createdAt} + FROM history + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at + ) VALUES (${messageId}, ${threadId}, 'user', 'Read these notes', + ${attachmentsJson}, 0, ${createdAt}, ${createdAt}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at + ) VALUES ('turn-start-unrelated-user', 'thread-turn-start-unrelated', 'user', 'Unrelated prompt', + 'not-json', 0, ${createdAt}, ${createdAt}) + `; + + const counter = makeSqlStatementCounter(); + const context = yield* query + .getTurnStartMessage({ threadId, messageId }) + .pipe(Effect.withTracer(counter.tracer)); + assert.equal(counter.count(), 1); + assert.deepEqual( + context, + Option.some({ + message: { + id: messageId, + role: "user", + text: "Read these notes", + turnId: null, + streaming: false, + createdAt, + updatedAt: createdAt, + attachments, + }, + hasOtherUserMessages: false, + }), + ); + assert.equal( + (yield* query.getTurnStartMessage({ + threadId: ThreadId.make("thread-turn-start-unrelated"), + messageId, + }))._tag, + "None", + ); + assert.equal( + (yield* query.getTurnStartMessage({ threadId, messageId: MessageId.make("missing") }))._tag, + "None", + ); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + DELETE FROM projection_thread_messages + WHERE thread_id IN ('thread-turn-start-read', 'thread-turn-start-unrelated') + `; + }).pipe(Effect.orDie), + ), + ), + ); + + it.effect("keeps compaction and queued-message eligibility in the turn-start query", () => + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-turn-start-eligibility"); + const messageId = MessageId.make("message-turn-start-eligibility"); + const createdAt = "2026-09-05T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, is_streaming, created_at, updated_at + ) VALUES (${messageId}, ${threadId}, 'user', 'Start a turn', 0, ${createdAt}, ${createdAt}) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, role, text, attachments_json, is_streaming, created_at, updated_at + ) VALUES ('turn-start-other-user', ${threadId}, 'user', '/compact', NULL, 0, + '2026-09-05T00:00:01.000Z', '2026-09-05T00:00:01.000Z') + `; + + for (const { text, attachments, hasOtherUserMessages } of [ + { text: "/compact", attachments: null, hasOtherUserMessages: false }, + { + text: "\t\n\r /CoMpAcT\u00a0\u2028\ufeff", + attachments: "[ ]", + hasOtherUserMessages: false, + }, + { text: "/compact keep recent errors", attachments: "[]", hasOtherUserMessages: true }, + { text: "", attachments: null, hasOtherUserMessages: true }, + { text: "Queued prompt", attachments: null, hasOtherUserMessages: true }, + { + text: "/compact", + attachments: + '[{"type":"file","id":"notes","name":"notes.txt","mimeType":"text/plain","sizeBytes":8}]', + hasOtherUserMessages: true, + }, + ]) { + yield* sql` + UPDATE projection_thread_messages SET text = ${text}, attachments_json = ${attachments} + WHERE message_id = 'turn-start-other-user' + `; + const context = yield* query.getTurnStartMessage({ threadId, messageId }); + assert.equal(context._tag, "Some"); + if (context._tag === "Some") { + assert.equal(context.value.hasOtherUserMessages, hasOtherUserMessages); + } + } + }), + ); + it.effect("keeps archived threads out of the main shell snapshot", () => Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 86e0b94573d9..d9f4526e9e63 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -91,6 +91,9 @@ 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; +// SQLite trim defaults to spaces. Match the whitespace removed by String.trim. +const MESSAGE_TRIM_WHITESPACE = + "\t\n\v\f\r \u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( Struct.assign({ defaultModelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), @@ -105,6 +108,9 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); +const ProjectionTurnStartMessageDbRowSchema = ProjectionThreadMessageDbRowSchema.mapFields( + Struct.assign({ hasOtherUserMessages: Schema.Number }), +); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ @@ -180,6 +186,10 @@ const ProjectionImportedAgentSessionSourcesRowSchema = Schema.Struct({ const ThreadIdLookupInput = Schema.Struct({ threadId: ThreadId, }); +const TurnStartMessageLookupInput = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, +}); const ThreadActivityKindsLookupInput = Schema.Struct({ threadId: ThreadId, activityKinds: Schema.Array(Schema.String), @@ -1117,6 +1127,37 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), }); + const getTurnStartMessageRow = SqlSchema.findOneOption({ + Request: TurnStartMessageLookupInput, + Result: ProjectionTurnStartMessageDbRowSchema, + execute: ({ threadId, messageId }) => sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + created_at AS "createdAt", + updated_at AS "updatedAt", + EXISTS ( + SELECT 1 + FROM projection_thread_messages AS other + WHERE other.thread_id = ${threadId} + AND other.message_id != ${messageId} + AND other.role = 'user' + AND ( + LOWER(TRIM(other.text, ${MESSAGE_TRIM_WHITESPACE})) != '/compact' + OR COALESCE(json_array_length(other.attachments_json), 0) > 0 + ) + ) AS "hasOtherUserMessages" + FROM projection_thread_messages + WHERE thread_id = ${threadId} AND message_id = ${messageId} + LIMIT 1 + `, + }); + const listThreadMessageRowsByThread = SqlSchema.findAll({ Request: ThreadIdLookupInput, Result: ProjectionThreadMessageDbRowSchema, @@ -2887,6 +2928,32 @@ pending_approval_requests AS ( })); }); + const getTurnStartMessage: ProjectionSnapshotQueryShape["getTurnStartMessage"] = Effect.fn( + "ProjectionSnapshotQuery.getTurnStartMessage", + )(function* (input) { + const message = yield* getTurnStartMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getTurnStartMessage:query", + "ProjectionSnapshotQuery.getTurnStartMessage:decodeRow", + ), + ), + ); + return Option.map(message, (row) => ({ + message: { + id: row.messageId, + role: row.role, + text: row.text, + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + }, + hasOtherUserMessages: row.hasOtherUserMessages === 1, + })); + }); + // Contiguous turn range bounding a windowed detail read; undefined loads the // full thread. Resolved from a window request inside the snapshot // transaction (see getThreadDetailSnapshot). @@ -3326,6 +3393,7 @@ pending_approval_requests AS ( getFullThreadDiffContext, getThreadShellById, getThreadRuntimeContext, + getTurnStartMessage, getThreadDetailById, getThreadDetailSnapshot, } satisfies ProjectionSnapshotQueryShape; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index a8b26fe52cbd..d93fec5a3cf6 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -909,6 +909,48 @@ describe("ProviderCommandReactor", () => { }), ); + effectIt.effect("starts a turn and generates its title without loading old message bodies", () => + Effect.gen(function* () { + const started = yield* Deferred.make(); + const titleGenerated = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + unreadableHistory: true, + startSessionEffect: (session) => + Deferred.succeed(started, undefined).pipe(Effect.as(session)), + }), + ); + harness.generateThreadTitle.mockReturnValue( + Deferred.succeed(titleGenerated, undefined).pipe(Effect.as({ title: "Generated title" })), + ); + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-with-old-history"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: MessageId.make("message-turn-start-with-old-history"), + role: "user", + text: "Use the current message", + attachments: [], + }, + titleSeed: "Thread", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: "2026-01-01T00:00:01.000Z", + }); + yield* Deferred.await(started); + yield* Deferred.await(titleGenerated); + yield* Effect.promise(() => harness.drain()); + + expect(harness.sendTurn).toHaveBeenCalledWith( + expect.objectContaining({ input: "Use the current message" }), + ); + expect(harness.generateThreadTitle).toHaveBeenCalledWith( + expect.objectContaining({ message: "Use the current message" }), + ); + }), + ); + effectIt.effect("rejects /compact without conversation context", () => Effect.gen(function* () { const harness = yield* Effect.promise(() => createHarness()); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index fc963bcc9cb2..5c1086b9e29c 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -1188,12 +1188,15 @@ const make = Effect.gen(function* () { return; } - const thread = yield* resolveThreadDetail(event.payload.threadId); + const thread = yield* resolveThreadShell(event.payload.threadId); if (!thread) { return; } - const message = thread.messages.find((entry) => entry.id === event.payload.messageId); - if (!message || message.role !== "user") { + const turnStart = yield* projectionSnapshotQuery.getTurnStartMessage({ + threadId: thread.id, + messageId: event.payload.messageId, + }); + if (Option.isNone(turnStart) || turnStart.value.message.role !== "user") { yield* appendProviderFailureActivity({ threadId: event.payload.threadId, kind: "provider.turn.start.failed", @@ -1205,6 +1208,7 @@ const make = Effect.gen(function* () { }); return; } + const { message, hasOtherUserMessages } = turnStart.value; const appendTurnStartFailure = (summary: string, detail: string) => appendProviderFailureActivity({ threadId: event.payload.threadId, @@ -1297,10 +1301,7 @@ const make = Effect.gen(function* () { yield* ensureThreadWorktree(thread); const isCompactCommand = isCompactCommandMessage(message); - const nonCompactUserMessageCount = thread.messages.filter( - (entry) => entry.role === "user" && !isCompactCommandMessage(entry), - ).length; - if (nonCompactUserMessageCount === 1 && !isCompactCommand) { + if (!hasOtherUserMessages && !isCompactCommand) { const project = yield* resolveProject(thread.projectId); const generationCwd = resolveThreadWorkspaceCwd({ @@ -1371,7 +1372,7 @@ const make = Effect.gen(function* () { ), ); if (isCompactCommand) { - if (nonCompactUserMessageCount === 0) { + if (!hasOtherUserMessages) { return yield* appendTurnStartFailure( "Context compaction failed", "Context compaction requires an existing conversation.", diff --git a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts index 37bcc1ae8f39..35d5bacc239c 100644 --- a/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts @@ -10,7 +10,9 @@ import type { AgentSessionImportSource, ApprovalRequestId, CheckpointRef, + MessageId, OrchestrationCheckpointSummary, + OrchestrationMessage, OrchestrationProject, OrchestrationProjectShell, OrchestrationReadModel, @@ -211,6 +213,21 @@ export interface ProjectionSnapshotQueryShape { ProjectionRepositoryError >; + /** + * Read one requested message and whether another non-compaction user message exists. + * Newer queued messages count too, preserving first-turn title eligibility. + */ + readonly getTurnStartMessage: (input: { + readonly threadId: ThreadId; + readonly messageId: MessageId; + }) => Effect.Effect< + Option.Option<{ + readonly message: OrchestrationMessage; + readonly hasOtherUserMessages: boolean; + }>, + ProjectionRepositoryError + >; + /** * Read a single active thread detail snapshot by id. */ diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index dc6d72a0ce63..aee64cf4b5d6 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -58,6 +58,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray< getFullThreadDiffContext: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), searchThreads: () => Effect.die("unused"), diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 225458d7142d..3a8c3ad71e69 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -46,6 +46,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) => getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index fecd7fca9096..0342f3ca79e3 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -4330,6 +4330,7 @@ describe("agent browser access", () => { Layer.provide(runtimeRepositoryLayer), ); const projectionLayer = Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getTurnStartMessage: () => Effect.die("unused"), getImportedAgentSessionSources: () => Effect.die("unused"), getUserInputActivity: () => Effect.die("unused"), getCommandReadModel: () => Effect.die("unused"), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index c680a8228e3b..4998f710c29b 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -221,6 +221,7 @@ describe("ProviderSessionReaper", () => { getThreadCheckpointContext: () => Effect.die("unused"), getFullThreadDiffContext: () => Effect.die("unused"), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: (threadId) => Effect.succeed( input.readModel.threads.find((thread) => thread.id === threadId) diff --git a/apps/server/src/serverRuntimeStartup.test.ts b/apps/server/src/serverRuntimeStartup.test.ts index 88e3c2e88588..0426df44bcea 100644 --- a/apps/server/src/serverRuntimeStartup.test.ts +++ b/apps/server/src/serverRuntimeStartup.test.ts @@ -171,6 +171,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -257,6 +258,7 @@ it.effect.each([ getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -325,6 +327,7 @@ it.effect( getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), @@ -386,6 +389,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa getThreadCheckpointContext: () => Effect.succeed(Option.none()), getFullThreadDiffContext: () => Effect.succeed(Option.none()), getThreadRuntimeContext: () => Effect.die("unused"), + getTurnStartMessage: () => Effect.die("unused"), getThreadShellById: () => Effect.die("unused"), getThreadDetailById: () => Effect.die("unused"), getThreadDetailSnapshot: () => Effect.die("unused"), From 076d753ae6cb97136b6b4199fb8fc0b379282dba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:18:05 -0700 Subject: [PATCH 213/320] perf(web): skip checkpoint map rebuilds while streaming (#10118) Co-authored-by: Claude Fable 5.1 --- .../web/src/components/ChatView.logic.test.ts | 125 ----- apps/web/src/components/ChatView.logic.ts | 48 -- apps/web/src/components/ChatView.tsx | 50 +- .../chat/MessagesTimeline.logic.test.ts | 443 +++++++++++++----- .../components/chat/MessagesTimeline.logic.ts | 89 +++- .../components/chat/MessagesTimeline.test.tsx | 33 +- .../src/components/chat/MessagesTimeline.tsx | 36 +- 7 files changed, 451 insertions(+), 373 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 47173520087a..820d431db4f0 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,6 +1,5 @@ import { ANTIGRAVITY_DEFAULT_MODEL, - CheckpointRef, EnvironmentId, MessageId, ProjectId, @@ -13,7 +12,6 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; -import type { TimelineEntry } from "../session-logic"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; import type { RightPanelSurface } from "../rightPanelStore"; @@ -24,7 +22,6 @@ import { branchMismatchKey, buildExpiredTerminalContextToastCopy, buildLoadingThreadFromShell, - buildRevertTurnCountByUserMessageId, buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, @@ -1127,128 +1124,6 @@ describe("resolveComposerInteractionMode", () => { }); }); -describe("buildRevertTurnCountByUserMessageId", () => { - const userMessageId = MessageId.make("rewind-user-message"); - const assistantMessageId = MessageId.make("rewind-assistant-message"); - const turnId = TurnId.make("rewind-turn"); - const timelineEntries = [ - { - id: userMessageId, - kind: "message", - createdAt: now, - message: { - id: userMessageId, - role: "user", - text: "Update the file", - turnId, - createdAt: now, - updatedAt: now, - streaming: false, - }, - }, - { - id: assistantMessageId, - kind: "message", - createdAt: now, - message: { - id: assistantMessageId, - role: "assistant", - text: "Updated the file", - turnId, - createdAt: now, - updatedAt: now, - streaming: false, - }, - }, - ] satisfies ReadonlyArray; - const turnDiffSummaryByAssistantMessageId = new Map([ - [ - assistantMessageId, - { - turnId, - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("refs/t3/checkpoints/rewind-turn"), - status: "ready", - files: [], - assistantMessageId, - completedAt: now, - }, - ], - ]); - - it("offers the checkpoint before the user message when conversation rollback is supported", () => { - expect( - buildRevertTurnCountByUserMessageId({ - supportsConversationRollback: true, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId: {}, - }), - ).toEqual(new Map([[userMessageId, 0]])); - }); - - it("offers no rewind action when file checkpoints exist but conversation rollback is unsupported", () => { - expect( - buildRevertTurnCountByUserMessageId({ - supportsConversationRollback: false, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId: {}, - }).size, - ).toBe(0); - }); - - it.each([true, false])( - "returns the previous map when contents are unchanged (rollback supported: %s)", - (supportsConversationRollback) => { - const input = { - supportsConversationRollback, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId: {}, - }; - const previous = buildRevertTurnCountByUserMessageId(input); - const streamed = timelineEntries.map((entry) => - entry.message.role === "assistant" - ? { ...entry, message: { ...entry.message, text: "Updated the file again" } } - : entry, - ); - - expect( - buildRevertTurnCountByUserMessageId({ ...input, timelineEntries: streamed }, previous), - ).toBe(previous); - }, - ); - - it("returns a new map when a revert target changes", () => { - const input = { - supportsConversationRollback: true, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId: {}, - }; - const previous = buildRevertTurnCountByUserMessageId(input); - const next = buildRevertTurnCountByUserMessageId( - { - ...input, - turnDiffSummaryByAssistantMessageId: new Map([ - [ - assistantMessageId, - { - ...turnDiffSummaryByAssistantMessageId.get(assistantMessageId)!, - checkpointTurnCount: 3, - }, - ], - ]), - }, - previous, - ); - - expect(next).not.toBe(previous); - expect(next).toEqual(new Map([[userMessageId, 2]])); - }); -}); - describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { const state = deriveComposerSendState({ diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index ff0b15071955..1faeb74c9863 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -38,7 +38,6 @@ import { } from "../types"; import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; -import { shallow } from "zustand/vanilla/shallow"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { environmentThreadDetails } from "../state/threads"; import { @@ -465,53 +464,6 @@ export function getAntigravitySendBlockReason( return null; } -/** - * Maps each user message to the checkpoint turn count a revert should target. - * Returns `previous` when the result is unchanged: streaming text deltas - * rebuild `timelineEntries` per token, and the timeline row projection only - * reuses rows while this Map keeps its identity. - */ -export function buildRevertTurnCountByUserMessageId( - input: { - supportsConversationRollback: boolean; - timelineEntries: ReadonlyArray; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - inferredCheckpointTurnCountByTurnId: Readonly>; - }, - previous: Map | null = null, -): Map { - const byUserMessageId = new Map(); - const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; - for (let index = 0; index < entryCount; index += 1) { - const entry = input.timelineEntries[index]; - if (!entry || entry.kind !== "message" || entry.message.role !== "user") { - continue; - } - - for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { - const nextEntry = input.timelineEntries[nextIndex]; - if (!nextEntry || nextEntry.kind !== "message") { - continue; - } - if (nextEntry.message.role === "user") { - break; - } - const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); - if (!summary) { - continue; - } - const turnCount = - summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId]; - if (typeof turnCount !== "number") { - break; - } - byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); - break; - } - } - return previous !== null && shallow(previous, byUserMessageId) ? previous : byUserMessageId; -} - export function reconcileMountedTerminalThreadIds(input: { currentThreadIds: ReadonlyArray; openThreadIds: ReadonlyArray; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c0c79c93bec1..3974837f5f21 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -153,11 +153,9 @@ import { isImageAttachment, type SessionPhase, type Thread, - type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; -import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -367,7 +365,6 @@ import { buildExpiredTerminalContextToastCopy, buildLocalDraftThread, buildLoadingThreadFromShell, - buildRevertTurnCountByUserMessageId, buildThreadTurnInterruptInput, collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, @@ -3101,35 +3098,6 @@ export default function ChatView(props: ChatViewProps) { attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, ] = useDraftHeroLayoutTransition(isDraftHeroState); - const { turnDiffSummaries, inferredCheckpointTurnCountByTurnId } = - useTurnDiffSummaries(activeThread); - const turnDiffSummaryByAssistantMessageId = useMemo(() => { - const byMessageId = new Map(); - for (const summary of turnDiffSummaries) { - if (!summary.assistantMessageId) continue; - byMessageId.set(summary.assistantMessageId, summary); - } - return byMessageId; - }, [turnDiffSummaries]); - const lastRevertTurnCountRef = useRef | null>(null); - const revertTurnCountByUserMessageId = useMemo(() => { - const next = buildRevertTurnCountByUserMessageId( - { - supportsConversationRollback, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId, - }, - lastRevertTurnCountRef.current, - ); - lastRevertTurnCountRef.current = next; - return next; - }, [ - supportsConversationRollback, - inferredCheckpointTurnCountByTurnId, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - ]); const gitCwd = activeProject ? projectScriptCwd({ @@ -7778,17 +7746,11 @@ export default function ChatView(props: ChatViewProps) { }, [activeThreadRef, isServerThread, onDiffPanelOpen], ); - // Both the Map and the revert handler are read from refs at call-time so - // the callback reference is fully stable and never busts context identity. - const revertTurnCountRef = useRef(revertTurnCountByUserMessageId); - revertTurnCountRef.current = revertTurnCountByUserMessageId; + // The revert handler is read from a ref at call-time so the callback + // reference is fully stable and never busts TimelineRowCtx identity. const onRevertToTurnCountRef = useRef(onRevertToTurnCount); onRevertToTurnCountRef.current = onRevertToTurnCount; - const onRevertUserMessage = useCallback((messageId: MessageId) => { - const targetTurnCount = revertTurnCountRef.current.get(messageId); - if (typeof targetTurnCount !== "number") { - return; - } + const onRevertTimelineTurn = useCallback((targetTurnCount: number) => { void onRevertToTurnCountRef.current(targetTurnCount); }, []); @@ -8104,12 +8066,12 @@ export default function ChatView(props: ChatViewProps) { timelineEntries={timelineEntries} latestTurn={activeLatestTurn} runningTurnId={activeRunningTurnId} - turnDiffSummaryByAssistantMessageId={turnDiffSummaryByAssistantMessageId} + turnDiffSummaries={activeThread.checkpoints} activeThreadEnvironmentId={activeThread.environmentId} routeThreadKey={routeThreadKey} onOpenTurnDiff={onOpenTurnDiff} - revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} - onRevertUserMessage={onRevertUserMessage} + supportsConversationRollback={supportsConversationRollback} + onRevertToTurnCount={onRevertTimelineTurn} onUseArtifactTemplate={useArtifactTemplate} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 5d310c5fe345..e1cf7f0dc6e7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,5 +1,22 @@ import { describe, expect, it } from "vite-plus/test"; -import { CheckpointRef, MessageId, TurnId } from "@t3tools/contracts"; +import { + CheckpointRef, + EnvironmentId, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationThread, +} from "@t3tools/contracts"; +import { + applyThreadDetailEvent, + createEnvironmentThreadDetailAtoms, + EMPTY_ENVIRONMENT_THREAD_STATE, +} from "@t3tools/client-runtime/state/threads"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; import { computeStableMessagesTimelineRows, computeMessageDurationStart, @@ -12,6 +29,7 @@ import { shouldFollowWorkGroupAppend, shouldPreserveAssistantLineBreaks, type MessagesTimelineRow, + type MessagesTimelineRowsProjection, workEntryDisplayLabel, } from "./MessagesTimeline.logic"; import { @@ -19,8 +37,8 @@ import { deriveTimelineEntries, deriveTimelineEntriesWithState, type WorkLogEntry, + type TimelineEntriesProjection, } from "../../session-logic"; -import { buildRevertTurnCountByUserMessageId } from "../ChatView.logic"; import { isImageAttachment, type ChatMessage, type TurnDiffSummary } from "../../types"; describe("streaming row projection", () => { @@ -97,8 +115,8 @@ describe("streaming row projection", () => { runningTurnId: turnId, isWorking: true, activeTurnStartedAt: time(5), - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, } satisfies Parameters[0]; return { messages, work, timeline, input, time, turnId, historyTurnId }; } @@ -243,52 +261,54 @@ describe("streaming row projection", () => { }, ); - it("reuses rows when the revert map is rebuilt from the streamed entries", () => { + it("owns checkpoint lookups across streaming and equal source snapshots", () => { const initial = fixture("Partial"); - const inferredCheckpointTurnCountByTurnId = { [initial.historyTurnId]: 1 }; - const turnDiffSummaryByAssistantMessageId = new Map([ - [ - MessageId.make("history-assistant"), - { - turnId: initial.historyTurnId, - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), - status: "ready", - files: [], - assistantMessageId: MessageId.make("history-assistant"), - completedAt: initial.time(4), - }, - ], - ]); - let revertMap: Map | null = null; - // Mirrors ChatView: the map is derived from each delta's entries. - const build = (timelineEntries: typeof initial.timeline.entries) => { - revertMap = buildRevertTurnCountByUserMessageId( - { - supportsConversationRollback: true, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - inferredCheckpointTurnCountByTurnId, - }, - revertMap, - ); - return { - ...initial.input, - timelineEntries, - turnDiffSummaryByAssistantMessageId, - revertTurnCountByUserMessageId: revertMap, - }; + let checkpointLookupReads = 0; + const summary: TurnDiffSummary = { + turnId: initial.historyTurnId, + get checkpointTurnCount() { + checkpointLookupReads += 1; + return 1; + }, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/history-turn"), + status: "ready", + files: [], + get assistantMessageId() { + checkpointLookupReads += 1; + return MessageId.make("history-assistant"); + }, + get completedAt() { + checkpointLookupReads += 1; + return initial.time(4); + }, }; - const previous = deriveMessagesTimelineRowsWithState(build(initial.timeline.entries)); + const input = { + ...initial.input, + turnDiffSummaries: [summary], + supportsConversationRollback: true, + expandedTurnIds: new Set([initial.historyTurnId]), + expandedWorkGroupIds: new Set(), + }; + const previous = deriveMessagesTimelineRowsWithState(input); + expect(checkpointLookupReads).toBeGreaterThan(0); expect(previous.rows.some((row) => row.kind === "message" && row.revertTurnCount === 0)).toBe( true, ); const last = initial.messages.at(-1)!; const messages = [...initial.messages.slice(0, -1), { ...last, text: "Partial token" }]; const timeline = deriveTimelineEntriesWithState(messages, [], initial.work, initial.timeline); - const next = deriveMessagesTimelineRowsWithState(build(timeline.entries), previous); - - expect(next.rows).toEqual(deriveMessagesTimelineRows(build(timeline.entries))); + const nextInput = { + ...input, + timelineEntries: timeline.entries, + turnDiffSummaries: [...input.turnDiffSummaries], + latestTurn: { ...input.latestTurn }, + expandedTurnIds: new Set(input.expandedTurnIds), + expandedWorkGroupIds: new Set(input.expandedWorkGroupIds), + }; + checkpointLookupReads = 0; + const next = deriveMessagesTimelineRowsWithState(nextInput, previous); + expect(checkpointLookupReads).toBe(0); + expect(next.rows).toEqual(deriveMessagesTimelineRows(nextInput)); for (const [index, row] of previous.rows.entries()) { if ((row.kind === "message" || row.kind === "assistant-meta") && row.message === last) { expect(next.rows[index]).toMatchObject({ message: { text: "Partial token" } }); @@ -296,6 +316,205 @@ describe("streaming row projection", () => { expect(next.rows[index]).toBe(row); } } + + const changed = deriveMessagesTimelineRowsWithState( + { ...nextInput, turnDiffSummaries: [{ ...summary, checkpointTurnCount: 3 }] }, + next, + ); + expect( + changed.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), + ).toMatchObject({ revertTurnCount: 2 }); + const unsupported = deriveMessagesTimelineRowsWithState( + { ...changed.input, supportsConversationRollback: false }, + changed, + ); + expect( + unsupported.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), + ).toMatchObject({ revertTurnCount: undefined }); + expect( + previous.rows.find((row) => row.kind === "message" && row.message.id === messages[0]?.id), + ).toMatchObject({ revertTurnCount: 0 }); + }); + + it("reuses long-thread rows through detail events, selectors, and attachment previews", () => { + const initial = fixture("Partial"); + let checkpointLookupReads = 0; + const history = Array.from({ length: 250 }, (_, index) => { + const turnId = TurnId.make(`older-turn-${index}`); + const user = { + ...initial.messages[0]!, + id: MessageId.make(`older-user-${index}`), + createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5)).toISOString(), + attachments: [ + { + type: "image" as const, + id: "image", + name: "image.png", + mimeType: "image/png", + sizeBytes: 42, + }, + ], + }; + const assistant = { + ...initial.messages[1]!, + id: MessageId.make(`older-assistant-${index}`), + turnId, + createdAt: new Date(Date.UTC(2026, 8, 3, 0, 0, index * 5 + 3)).toISOString(), + }; + const checkpoint: TurnDiffSummary = { + turnId, + get checkpointTurnCount() { + checkpointLookupReads += 1; + return index + 1; + }, + checkpointRef: CheckpointRef.make(`refs/t3/checkpoints/older-${index}`), + status: "ready", + files: [], + get assistantMessageId() { + checkpointLookupReads += 1; + return assistant.id; + }, + get completedAt() { + checkpointLookupReads += 1; + return assistant.createdAt; + }, + }; + return { user, assistant, checkpoint }; + }); + const liveMessage = initial.messages.at(-1)!; + let thread: OrchestrationThread = { + id: ThreadId.make("streaming-thread"), + projectId: ProjectId.make("project"), + title: "Long thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: { + ...initial.input.latestTurn, + requestedAt: initial.time(5), + assistantMessageId: liveMessage.id, + }, + createdAt: initial.time(0), + updatedAt: initial.time(7), + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [ + ...history.flatMap(({ user, assistant }) => [user, assistant]), + ...initial.messages, + ], + proposedPlans: [], + activities: [], + checkpoints: history.map(({ checkpoint }) => checkpoint), + session: null, + }; + const state = Atom.make( + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }), + ); + const details = createEnvironmentThreadDetailAtoms(() => state); + const ref = { environmentId: EnvironmentId.make("local"), threadId: thread.id }; + const registry = AtomRegistry.make(); + const unmount = registry.mount(details.detailAtom(ref)); + const preview = createMessageAttachmentPreviewProjector(); + let imageUrl = "https://first.test/image"; + let timeline: TimelineEntriesProjection | null = null; + let projection: MessagesTimelineRowsProjection | null = null; + const project = () => { + const selected = registry.get(details.detailAtom(ref)); + if (selected === null) throw new Error("Missing thread detail"); + const messages = selected.messages.map((message) => preview(message, () => imageUrl)); + timeline = deriveTimelineEntriesWithState( + messages, + selected.proposedPlans, + initial.work, + timeline, + ); + projection = deriveMessagesTimelineRowsWithState( + { + timelineEntries: timeline.entries, + latestTurn: selected.latestTurn, + runningTurnId: + selected.latestTurn?.state === "running" ? selected.latestTurn.turnId : null, + isWorking: selected.latestTurn?.state === "running", + activeTurnStartedAt: selected.latestTurn?.startedAt ?? null, + turnDiffSummaries: selected.checkpoints, + supportsConversationRollback: true, + }, + projection, + ); + return projection; + }; + const send = (text: string, sequence: number, streaming = true) => { + const result = applyThreadDetailEvent(thread, { + eventId: EventId.make(`delta-${sequence}`), + sequence, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + occurredAt: initial.time(8 + sequence), + aggregateKind: "thread", + aggregateId: thread.id, + type: "thread.message-sent", + payload: { + threadId: thread.id, + messageId: liveMessage.id, + role: "assistant", + text, + turnId: initial.turnId, + streaming, + createdAt: liveMessage.createdAt, + updatedAt: initial.time(8 + sequence), + }, + }); + if (result.kind !== "updated") throw new Error("Message event did not update the thread"); + thread = result.thread; + registry.set( + state, + AsyncResult.success({ ...EMPTY_ENVIRONMENT_THREAD_STATE, data: Option.some(thread) }), + ); + return project(); + }; + + try { + const first = project(); + const saved = structuredClone(first.rows); + expect(checkpointLookupReads).toBeGreaterThan(0); + checkpointLookupReads = 0; + for (let index = 0; index < 10; index += 1) { + const next = send(` ${index}`, index + 1); + for (const [rowIndex, row] of first.rows.entries()) { + if ( + (row.kind === "message" || row.kind === "assistant-meta") && + row.message.id === liveMessage.id + ) + continue; + expect(next.rows[rowIndex]).toBe(row); + } + } + expect(checkpointLookupReads).toBe(0); + const streamed = project(); + expect(streamed.rows).toEqual(deriveMessagesTimelineRows(streamed.input)); + + imageUrl = "https://renewed.test/image"; + const renewed = project(); + expect(renewed.rows[0]).not.toBe(first.rows[0]); + expect(renewed.rows[0]).toMatchObject({ + message: { attachments: [{ previewUrl: imageUrl }] }, + }); + const completed = send("Complete", 11, false); + expect(completed.rows).toEqual(deriveMessagesTimelineRows(completed.input)); + expect( + completed.rows.find((row) => row.kind === "message" && row.message.id === liveMessage.id), + ).toMatchObject({ message: { text: "Complete" }, assistantCopyStreaming: false }); + expect(first.rows).toEqual(saved); + } finally { + unmount(); + registry.dispose(); + } }); it.each(["completion", "turn", "role", "ordering"] as const)( @@ -407,7 +626,7 @@ describe("streaming row projection", () => { ? { ...message, role: "user", turnId: null, createdAt: initial.time(0) } : message, ); - check({ revertTurnCountByUserMessageId: new Map([[MessageId.make("live-user"), 3]]) }); + check({ supportsConversationRollback: true }); }); }); @@ -574,8 +793,8 @@ describe("work entry labels", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const directRow = rows.find((row) => row.kind === "work"); expect(directRow).toMatchObject({ @@ -887,8 +1106,8 @@ describe("deriveMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows).toEqual([ @@ -950,8 +1169,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set(["turn-1" as never]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRows = rows.filter( @@ -1004,8 +1223,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRows = rows.filter( @@ -1061,10 +1280,8 @@ describe("deriveMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map([ - ["assistant-1" as never, assistantTurnDiffSummary], - ]), - revertTurnCountByUserMessageId: new Map([["user-1" as never, 1]]), + turnDiffSummaries: [assistantTurnDiffSummary], + supportsConversationRollback: true, }); const userRow = rows.find( @@ -1142,8 +1359,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const foldRow = collapsedRows.find( @@ -1165,8 +1382,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set(["turn-1" as never]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(expandedRows.map((row) => row.id)).toEqual([ @@ -1235,8 +1452,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const rows = deriveMessagesTimelineRows({ ...input, timelineEntries }); @@ -1318,8 +1535,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.id)).toEqual(["turn-fold:turn-1", "assistant-final-entry"]); @@ -1421,8 +1638,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:14Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const foldRow = rows.find( @@ -1458,8 +1675,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows).toEqual([ @@ -1526,8 +1743,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.id)).toEqual([ @@ -1580,8 +1797,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); @@ -1674,8 +1891,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.some((row) => row.kind === "turn-fold")).toBe(false); @@ -1754,8 +1971,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live"]); @@ -1827,8 +2044,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.kind)).toEqual(["working", "work", "message", "work-live"]); @@ -1879,8 +2096,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set([turnId]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.find((row) => row.kind === "work")).toMatchObject({ @@ -1947,8 +2164,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.map((row) => row.kind)).toEqual(["working", "work-live", "message", "work-live"]); @@ -1994,8 +2211,8 @@ describe("deriveMessagesTimelineRows", () => { latestTurn: null, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.some((row) => row.kind === "work-live")).toBe(false); @@ -2055,8 +2272,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.filter((row) => row.kind === "work-live").map((row) => row.entry.id)).toEqual([ @@ -2100,8 +2317,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const workLiveRow = rows.find((row) => row.kind === "work-live"); @@ -2148,8 +2365,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const initialRows = deriveRows(null); @@ -2226,8 +2443,8 @@ describe("deriveMessagesTimelineRows", () => { runningTurnId: "turn-2" as never, isWorking: true, activeTurnStartedAt: "2026-01-01T00:01:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.filter((row) => row.kind === "turn-fold").map((row) => row.turnId)).toEqual([ @@ -2271,8 +2488,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set(["turn-1" as never]), isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRows = rows.filter( @@ -2309,8 +2526,8 @@ describe("deriveMessagesTimelineRows", () => { }, isWorking: true, activeTurnStartedAt: "2026-01-01T00:00:00Z", - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const assistantRow = rows.find( @@ -2373,8 +2590,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const collapsedRows = deriveMessagesTimelineRows(baseInput); const expandedRows = deriveMessagesTimelineRows({ @@ -2467,8 +2684,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(row).toMatchObject({ @@ -2509,8 +2726,8 @@ describe("deriveMessagesTimelineRows", () => { expandedTurnIds: new Set([turnId]), runningTurnId: isWorking ? turnId : null, activeTurnStartedAt: isWorking ? createdAt : null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const expandedRows = deriveMessagesTimelineRows({ ...input, @@ -2549,8 +2766,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ @@ -2596,8 +2813,8 @@ describe("deriveMessagesTimelineRows", () => { timelineEntries, isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); expect(rows.find((row) => row.kind === "work-toggle")).toMatchObject({ @@ -2653,8 +2870,8 @@ describe("computeStableMessagesTimelineRows", () => { runningTurnId: turnId, isWorking: true, activeTurnStartedAt: startedAt, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }; const assistantEntry = { id: "assistant-entry", @@ -2731,8 +2948,8 @@ describe("computeStableMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const initial = computeStableMessagesTimelineRows(rows, { @@ -2780,8 +2997,8 @@ describe("computeStableMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const firstRows = createRows(); @@ -2836,8 +3053,8 @@ describe("computeStableMessagesTimelineRows", () => { ], isWorking: false, activeTurnStartedAt: null, - turnDiffSummaryByAssistantMessageId: new Map(), - revertTurnCountByUserMessageId: new Map(), + turnDiffSummaries: [], + supportsConversationRollback: false, }); const initial = computeStableMessagesTimelineRows(firstRows, { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 1288cd6fad8b..08e4f8d26037 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -18,6 +18,7 @@ export { } from "@t3tools/client-runtime/work-log/presentation"; import { formatDuration, + inferCheckpointTurnCountByTurnId, isStreamingMessageTextUpdate, workEntryDisplayIndicatesToolFailure, workEntryIndicatesToolSuccess, @@ -776,6 +777,45 @@ function attachTrailingToolGroupsToAssistant( return result; } +/** Match each user message to the next assistant checkpoint. */ +function buildRevertTurnCountByUserMessageId(input: { + supportsConversationRollback: boolean; + timelineEntries: ReadonlyArray; + turnDiffSummaryByAssistantMessageId: ReadonlyMap; + inferredCheckpointTurnCountByTurnId: Readonly>; +}): Map { + const byUserMessageId = new Map(); + const entryCount = input.supportsConversationRollback ? input.timelineEntries.length : 0; + for (let index = 0; index < entryCount; index += 1) { + const entry = input.timelineEntries[index]; + if (!entry || entry.kind !== "message" || entry.message.role !== "user") { + continue; + } + + for (let nextIndex = index + 1; nextIndex < input.timelineEntries.length; nextIndex += 1) { + const nextEntry = input.timelineEntries[nextIndex]; + if (!nextEntry || nextEntry.kind !== "message") { + continue; + } + if (nextEntry.message.role === "user") { + break; + } + const summary = input.turnDiffSummaryByAssistantMessageId.get(nextEntry.message.id); + if (!summary) { + continue; + } + const turnCount = + summary.checkpointTurnCount ?? input.inferredCheckpointTurnCountByTurnId[summary.turnId]; + if (typeof turnCount !== "number") { + break; + } + byUserMessageId.set(entry.message.id, Math.max(0, turnCount - 1)); + break; + } + } + return byUserMessageId; +} + export function deriveMessagesTimelineRows(input: { timelineEntries: ReadonlyArray; latestTurn?: TimelineLatestTurn | null; @@ -784,9 +824,23 @@ export function deriveMessagesTimelineRows(input: { expandedWorkGroupIds?: ReadonlySet; isWorking: boolean; activeTurnStartedAt: string | null; - turnDiffSummaryByAssistantMessageId: ReadonlyMap; - revertTurnCountByUserMessageId: ReadonlyMap; + turnDiffSummaries: ReadonlyArray; + supportsConversationRollback: boolean; }): MessagesTimelineRow[] { + const turnDiffSummaryByAssistantMessageId = new Map(); + for (const summary of input.turnDiffSummaries) { + if (summary.assistantMessageId) { + turnDiffSummaryByAssistantMessageId.set(summary.assistantMessageId, summary); + } + } + const revertTurnCountByUserMessageId = buildRevertTurnCountByUserMessageId({ + supportsConversationRollback: input.supportsConversationRollback, + timelineEntries: input.timelineEntries, + turnDiffSummaryByAssistantMessageId, + inferredCheckpointTurnCountByTurnId: input.supportsConversationRollback + ? inferCheckpointTurnCountByTurnId(input.turnDiffSummaries) + : {}, + }); const nextRows: MessagesTimelineRow[] = []; const durationStartByMessageId = computeMessageDurationStart( input.timelineEntries.flatMap((entry) => (entry.kind === "message" ? [entry.message] : [])), @@ -1134,11 +1188,11 @@ export function deriveMessagesTimelineRows(input: { assistantCopyStreaming: timelineEntry.message.streaming || assistantResponseStillInProgress, assistantTurnDiffSummary: timelineEntry.message.role === "assistant" - ? input.turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) + ? turnDiffSummaryByAssistantMessageId.get(timelineEntry.message.id) : undefined, revertTurnCount: timelineEntry.message.role === "user" - ? input.revertTurnCountByUserMessageId.get(timelineEntry.message.id) + ? revertTurnCountByUserMessageId.get(timelineEntry.message.id) : undefined, }); } @@ -1168,9 +1222,30 @@ function replaceStreamingMessageRows( input: MessagesTimelineRowsInput, previous: MessagesTimelineRowsProjection, ): MessagesTimelineRow[] | null { - const { timelineEntries: previousEntries, ...previousContext } = previous.input; - const { timelineEntries, ...context } = input; - if (timelineEntries.length !== previousEntries.length || !shallow(previousContext, context)) { + const { + timelineEntries: previousEntries, + turnDiffSummaries: previousSummaries, + latestTurn: previousLatestTurn, + expandedTurnIds: previousExpandedTurns, + expandedWorkGroupIds: previousExpandedGroups, + ...previousContext + } = previous.input; + const { + timelineEntries, + turnDiffSummaries, + latestTurn, + expandedTurnIds, + expandedWorkGroupIds, + ...context + } = input; + if ( + timelineEntries.length !== previousEntries.length || + !shallow(previousContext, context) || + !shallow(previousSummaries, turnDiffSummaries) || + !shallow(previousLatestTurn, latestTurn) || + !shallow(previousExpandedTurns, expandedTurnIds) || + !shallow(previousExpandedGroups, expandedWorkGroupIds) + ) { return null; } const replacements = new Map(); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 21f30fa625d6..fe3e9ab4417c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -184,11 +184,11 @@ function buildProps() { listRef: createRef(), latestTurn: null, runningTurnId: null, - turnDiffSummaryByAssistantMessageId: new Map(), + turnDiffSummaries: [], routeThreadKey: "environment-local:thread-1", onOpenTurnDiff: () => {}, - revertTurnCountByUserMessageId: new Map(), - onRevertUserMessage: () => {}, + supportsConversationRollback: false, + onRevertToTurnCount: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, @@ -448,22 +448,17 @@ describe("MessagesTimeline", () => { }, }, ]} - turnDiffSummaryByAssistantMessageId={ - new Map([ - [ - assistantMessageId, - { - turnId, - checkpointTurnCount: 1, - checkpointRef: CheckpointRef.make("checkpoint-with-files"), - status: "ready", - files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], - assistantMessageId, - completedAt: MESSAGE_CREATED_AT, - }, - ], - ]) - } + turnDiffSummaries={[ + { + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("checkpoint-with-files"), + status: "ready", + files: [{ path: "README.md", kind: "modified", additions: 2, deletions: 1 }], + assistantMessageId, + completedAt: MESSAGE_CREATED_AT, + }, + ]} />, ); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c1b09899b8de..a113157aca33 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -203,7 +203,7 @@ interface TimelineRowSharedState { workspaceRoot: string | undefined; skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; - onRevertUserMessage: (messageId: MessageId) => void; + onRevertToTurnCount: (targetTurnCount: number) => void; onUseArtifactTemplate: (template: CodexArtifactTemplate) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onFileOpen: (attachment: ChatFileAttachment) => void; @@ -311,11 +311,11 @@ interface MessagesTimelineProps { timelineEntries: ReturnType; latestTurn: TimelineLatestTurn | null; runningTurnId: TurnId | null; - turnDiffSummaryByAssistantMessageId: Map; + turnDiffSummaries: ReadonlyArray; routeThreadKey: string; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; - revertTurnCountByUserMessageId: Map; - onRevertUserMessage: (messageId: MessageId) => void; + supportsConversationRollback: boolean; + onRevertToTurnCount: (targetTurnCount: number) => void; onUseArtifactTemplate?: (template: CodexArtifactTemplate) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -369,11 +369,11 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timelineEntries, latestTurn, runningTurnId, - turnDiffSummaryByAssistantMessageId, + turnDiffSummaries, routeThreadKey, onOpenTurnDiff, - revertTurnCountByUserMessageId, - onRevertUserMessage, + supportsConversationRollback, + onRevertToTurnCount, onUseArtifactTemplate = NOOP_USE_ARTIFACT_TEMPLATE, isRevertingCheckpoint, onImageExpand, @@ -543,8 +543,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds, isWorking, activeTurnStartedAt, - turnDiffSummaryByAssistantMessageId, - revertTurnCountByUserMessageId, + turnDiffSummaries, + supportsConversationRollback, }, previous?.threadKey === routeThreadKey && previous.workspaceRoot === workspaceRoot ? previous.projection @@ -563,8 +563,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ expandedWorkGroupIds, isWorking, activeTurnStartedAt, - turnDiffSummaryByAssistantMessageId, - revertTurnCountByUserMessageId, + turnDiffSummaries, + supportsConversationRollback, ]); const rows = useStableRows(rawRows); const minimapItems = useMemo(() => deriveTimelineMinimapItems(rows), [rows]); @@ -729,7 +729,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, - onRevertUserMessage, + onRevertToTurnCount, onUseArtifactTemplate, onImageExpand, onFileOpen, @@ -753,7 +753,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ workspaceRoot, skills, activeThreadEnvironmentId, - onRevertUserMessage, + onRevertToTurnCount, onUseArtifactTemplate, onImageExpand, onFileOpen, @@ -1336,7 +1336,7 @@ function UserTimelineRow({ row }: { row: Extract image.name.startsWith("preview-annotation-")); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); - const canRevertAgentWork = typeof row.revertTurnCount === "number"; + const revertTurnCount = row.revertTurnCount; return (
@@ -1498,7 +1498,9 @@ function UserTimelineRow({ row }: { row: Extract
- {canRevertAgentWork && } + {typeof revertTurnCount === "number" && ( + + )} {displayedUserMessage.copyText && ( )} @@ -1509,7 +1511,7 @@ function UserTimelineRow({ row }: { row: Extract ctx.onRevertUserMessage(messageId)} + onClick={() => ctx.onRevertToTurnCount(turnCount)} aria-label="Revert to this message" /> } From eb8ed80300bd719b49fc80ad8708f9839bb48c92 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:21:03 -0700 Subject: [PATCH 214/320] perf(server): skip plan bodies in thread summaries (#10341) --- .../Layers/ProjectionPipeline.test.ts | 5 +- .../Layers/ProjectionPipeline.ts | 38 +--- .../Layers/ProjectionRepositories.test.ts | 211 +++++++++++++++++- .../Layers/ProjectionThreadProposedPlans.ts | 52 +++++ .../Services/ProjectionThreadProposedPlans.ts | 10 + 5 files changed, 280 insertions(+), 36 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index d81d9b11b1f6..eb67d8a5b85d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -2697,7 +2697,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); - it.effect("maintains shell summaries without reading message bodies", () => + it.effect("maintains shell summaries without decoding message or plan bodies", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; const eventStore = yield* OrchestrationEventStore; @@ -2923,12 +2923,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { ('summary-other-thread', 'thread-shell-summary-other', NULL, 'pending', NULL, '2026-03-01T08:00:06.000Z', NULL) `; + // Empty markdown must not be decoded when the shell only needs plan status. yield* sql` INSERT INTO projection_thread_proposed_plans ( plan_id, thread_id, turn_id, plan_markdown, implemented_at, implementation_thread_id, created_at, updated_at ) VALUES ( - 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '# Plan', NULL, + 'summary-plan', 'thread-shell-summary', 'turn-shell-summary-1', '', NULL, NULL, '2026-03-01T08:00:06.000Z', '2026-03-01T08:00:06.000Z' ) `; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index b338596993d2..7c71d7b6ee60 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -197,33 +197,6 @@ function derivePendingUserInputCountFromActivities( return openRequestIds.size; } -function deriveHasActionableProposedPlan(input: { - readonly latestTurnId: string | null; - readonly proposedPlans: ReadonlyArray; -}): boolean { - const sorted = [...input.proposedPlans].toSorted( - (left, right) => - left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId), - ); - - let latestForTurn: ProjectionThreadProposedPlan | null = null; - if (input.latestTurnId !== null) { - for (let index = sorted.length - 1; index >= 0; index -= 1) { - const plan = sorted[index]; - if (plan?.turnId === input.latestTurnId) { - latestForTurn = plan; - break; - } - } - } - if (latestForTurn !== null) { - return latestForTurn.implementedAt === null; - } - - const latestPlan = sorted.at(-1) ?? null; - return latestPlan !== null && latestPlan.implementedAt === null; -} - function retainProjectionMessagesAfterRevert( messages: ReadonlyArray, turns: ReadonlyArray, @@ -595,19 +568,18 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } - const [latestUserMessageAt, proposedPlans, activities, pendingApprovalCount] = + const [latestUserMessageAt, hasActionableProposedPlan, activities, pendingApprovalCount] = yield* Effect.all([ projectionThreadMessageRepository.getLatestUserMessageAt({ threadId }), - projectionThreadProposedPlanRepository.listByThreadId({ threadId }), + projectionThreadProposedPlanRepository.hasActionableByThreadId({ + threadId, + latestTurnId: existingRow.value.latestTurnId, + }), projectionThreadActivityRepository.listUserInputLifecycleByThreadId({ threadId }), projectionPendingApprovalRepository.countPendingByThreadId({ threadId }), ]); const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities); - const hasActionableProposedPlan = deriveHasActionableProposedPlan({ - latestTurnId: existingRow.value.latestTurnId, - proposedPlans, - }); yield* projectionThreadRepository.upsert({ ...existingRow.value, diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index adc3ca40cbb5..4f56a743d1d5 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,25 +1,234 @@ -import { ProjectId, ThreadId, ProviderInstanceId } from "@t3tools/contracts"; +import { ProjectId, ThreadId, TurnId, ProviderInstanceId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Statement from "effect/unstable/sql/Statement"; import { SqlitePersistenceMemory } from "./Sqlite.ts"; import { ProjectionProjectRepositoryLive } from "./ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "./ProjectionThreads.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "./ProjectionThreadProposedPlans.ts"; import { ProjectionProjectRepository } from "../Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../Services/ProjectionThreads.ts"; +import { ProjectionThreadProposedPlanRepository } from "../Services/ProjectionThreadProposedPlans.ts"; const projectionRepositoriesLayer = it.layer( Layer.mergeAll( ProjectionProjectRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), ProjectionThreadRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), + ProjectionThreadProposedPlanRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), SqlitePersistenceMemory, ), ); projectionRepositoriesLayer("Projection repositories", (it) => { + it.effect("selects the latest-turn plan before checking implementation status", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const threadId = ThreadId.make("thread-plan-status"); + const latestTurnId = TurnId.make("turn-plan-status-current"); + const firstPlan = { + planId: "plan-status-first", + threadId, + turnId: latestTurnId, + planMarkdown: "# First plan", + implementedAt: null, + implementationThreadId: null, + createdAt: "2026-03-24T00:00:01.000Z", + updatedAt: "2026-03-24T00:00:01.000Z", + }; + yield* plans.upsert(firstPlan); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-status-implemented", + implementedAt: "2026-03-24T00:00:02.000Z", + createdAt: "2026-03-24T00:00:02.000Z", + updatedAt: "2026-03-24T00:00:02.000Z", + }); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-status-other-turn", + turnId: TurnId.make("turn-plan-status-old"), + updatedAt: "2026-03-24T00:00:10.000Z", + }); + + assert.isFalse(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null })); + + yield* plans.upsert({ ...firstPlan, updatedAt: "2026-03-24T00:00:03.000Z" }); + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + }), + ); + + it.effect("falls back within the thread when the latest turn has no plan", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const threadId = ThreadId.make("thread-plan-fallback"); + const latestTurnId = TurnId.make("turn-plan-fallback-missing"); + assert.isFalse(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + assert.isFalse(yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null })); + + const firstPlan = { + planId: "plan-fallback-without-turn", + threadId, + turnId: null, + planMarkdown: "# Old plan", + implementedAt: "2026-03-24T00:00:01.000Z", + implementationThreadId: null, + createdAt: "2026-03-24T00:00:01.000Z", + updatedAt: "2026-03-24T00:00:01.000Z", + }; + yield* plans.upsert(firstPlan); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-fallback-with-turn", + turnId: TurnId.make("turn-plan-fallback-old"), + implementedAt: null, + updatedAt: "2026-03-24T00:00:02.000Z", + }); + yield* plans.upsert({ + ...firstPlan, + planId: "plan-fallback-other-thread", + threadId: ThreadId.make("thread-plan-fallback-other"), + turnId: latestTurnId, + updatedAt: "2026-03-24T00:00:03.000Z", + }); + + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId })); + assert.isTrue(yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null })); + }), + ); + + it.effect("preserves locale ordering and stable ties when selecting plan status", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const timestamp = "2026-03-24T00:00:00.000Z"; + const cases = [ + { + name: "mixed-case-ids", + expected: "plan-A".localeCompare("plan-a") > 0, + rows: [ + { + planId: "plan-a", + implementedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }, + { planId: "plan-A", implementedAt: null, createdAt: timestamp, updatedAt: timestamp }, + ], + }, + { + name: "equivalent-ids", + expected: true, + rows: [ + { + planId: "plan-\u00e9", + implementedAt: timestamp, + createdAt: timestamp, + updatedAt: timestamp, + }, + { + planId: "plan-e\u0301", + implementedAt: null, + createdAt: "2026-03-24T00:00:01.000Z", + updatedAt: timestamp, + }, + ], + }, + { + name: "timestamp-formats", + expected: "2026-03-24T00:00:00+00:00".localeCompare("2026-03-24T00:00:00-01:00") > 0, + rows: [ + { + planId: "plan-minus", + implementedAt: timestamp, + createdAt: timestamp, + updatedAt: "2026-03-24T00:00:00-01:00", + }, + { + planId: "plan-plus", + implementedAt: null, + createdAt: timestamp, + updatedAt: "2026-03-24T00:00:00+00:00", + }, + ], + }, + ]; + for (const testCase of cases) { + const threadId = ThreadId.make(`thread-plan-order-${testCase.name}`); + const latestTurnId = TurnId.make(`turn-plan-order-${testCase.name}`); + for (const plan of testCase.rows) { + yield* plans.upsert({ + ...plan, + planId: `${testCase.name}-${plan.planId}`, + threadId, + turnId: latestTurnId, + planMarkdown: "# Plan", + implementationThreadId: null, + }); + } + assert.strictEqual( + yield* plans.hasActionableByThreadId({ threadId, latestTurnId }), + testCase.expected, + testCase.name, + ); + assert.strictEqual( + yield* plans.hasActionableByThreadId({ threadId, latestTurnId: null }), + testCase.expected, + testCase.name, + ); + } + }), + ); + + it.effect("returns only current-turn status metadata when old plans have large bodies", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-plan-metadata"); + const latestTurnId = TurnId.make("turn-plan-metadata-current"); + const updatedAt = "2026-03-24T00:00:00.000Z"; + yield* sql` + WITH RECURSIVE history(n) AS ( + SELECT 1 UNION ALL SELECT n + 1 FROM history WHERE n < 256 + ) + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, implemented_at, + implementation_thread_id, created_at, updated_at + ) + SELECT 'plan-metadata-old-' || n, ${threadId}, 'turn-plan-metadata-old', + ${"# Old plan\n".repeat(1024)}, ${updatedAt}, NULL, ${updatedAt}, ${updatedAt} + FROM history + `; + yield* plans.upsert({ + planId: "plan-metadata-current", + threadId, + turnId: latestTurnId, + planMarkdown: "# Current plan", + implementedAt: null, + implementationThreadId: null, + createdAt: updatedAt, + updatedAt, + }); + const statements: Array> = []; + const actionable = yield* plans.hasActionableByThreadId({ threadId, latestTurnId }).pipe( + Effect.provideService(Statement.CurrentTransformer, (statement) => { + statements.push(statement); + return Effect.succeed(statement); + }), + ); + assert.isTrue(actionable); + assert.strictEqual(statements.length, 1); + const statement = statements[0]; + if (statement === undefined) return yield* Effect.die("Expected a plan status query."); + assert.deepEqual(yield* statement, [ + { planId: "plan-metadata-current", implementedAt: null, updatedAt }, + ]); + }), + ); + it.effect("stores SQL NULL for missing project model options", () => Effect.gen(function* () { const projects = yield* ProjectionProjectRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 63aed1a16704..38b113a1b703 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -1,11 +1,13 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadProposedPlansInput, + HasActionableProjectionThreadProposedPlanInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, @@ -77,6 +79,55 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const listPlanStatusCandidates = SqlSchema.findAll({ + Request: HasActionableProjectionThreadProposedPlanInput, + Result: Schema.Struct({ + planId: ProjectionThreadProposedPlan.fields.planId, + implementedAt: ProjectionThreadProposedPlan.fields.implementedAt, + updatedAt: ProjectionThreadProposedPlan.fields.updatedAt, + }), + execute: ({ threadId, latestTurnId }) => sql` + SELECT + plan_id AS "planId", + implemented_at AS "implementedAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} + AND ( + turn_id = ${latestTurnId} + OR NOT EXISTS ( + SELECT 1 FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} AND turn_id = ${latestTurnId} + ) + ) + ORDER BY created_at ASC, plan_id ASC + `, + }); + + const hasActionableByThreadId = Effect.fn( + "ProjectionThreadProposedPlanRepository.hasActionableByThreadId", + )( + function* (input: HasActionableProjectionThreadProposedPlanInput) { + const candidates = yield* listPlanStatusCandidates(input); + let selected: (typeof candidates)[number] | undefined; + // Timestamps and IDs use localeCompare, not SQLite byte order. Replace + // equal candidates to preserve the stable order of listByThreadId. + for (const candidate of candidates) { + if ( + selected === undefined || + (candidate.updatedAt.localeCompare(selected.updatedAt) || + candidate.planId.localeCompare(selected.planId)) >= 0 + ) { + selected = candidate; + } + } + return selected?.implementedAt === null; + }, + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.hasActionableByThreadId:query"), + ), + ); + const upsert: ProjectionThreadProposedPlanRepositoryShape["upsert"] = (row) => upsertProjectionThreadProposedPlanRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), @@ -101,6 +152,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { return { upsert, listByThreadId, + hasActionableByThreadId, deleteByThreadId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index b4bc2bcc3289..c8724efca9e3 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -29,6 +29,13 @@ export const ListProjectionThreadProposedPlansInput = Schema.Struct({ export type ListProjectionThreadProposedPlansInput = typeof ListProjectionThreadProposedPlansInput.Type; +export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({ + threadId: ThreadId, + latestTurnId: Schema.NullOr(TurnId), +}); +export type HasActionableProjectionThreadProposedPlanInput = + typeof HasActionableProjectionThreadProposedPlanInput.Type; + export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ threadId: ThreadId, }); @@ -42,6 +49,9 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly listByThreadId: ( input: ListProjectionThreadProposedPlansInput, ) => Effect.Effect, ProjectionRepositoryError>; + readonly hasActionableByThreadId: ( + input: HasActionableProjectionThreadProposedPlanInput, + ) => Effect.Effect; readonly deleteByThreadId: ( input: DeleteProjectionThreadProposedPlansInput, ) => Effect.Effect; From 62f568b88b58e1e7cf422c21ee2e08f10977b714 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:56:28 -0700 Subject: [PATCH 215/320] fix(server): skip disabled provider instances for text generation fallback (#10346) Co-authored-by: Claude Fable 5.1 --- apps/server/src/serverSettings.test.ts | 18 ++++++++++++++++++ apps/server/src/serverSettings.ts | 9 ++++++++- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 6e837308e8cb..9769ebf0a5f6 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -656,6 +656,24 @@ it.layer(NodeServices.layer)("server settings", (it) => { }).pipe(Effect.provide(makeServerSettingsLayer())), ); + it.effect("skips a disabled provider instance when picking the text generation fallback", () => + Effect.gen(function* () { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const serverSettings = yield* ServerSettingsModule.ServerSettingsService; + // The Providers UI writes providerInstances only, so the legacy providers + // map decodes to defaults where codex is enabled and listed first. + yield* fileSystem.writeFileString( + serverConfig.settingsPath, + '{"providerInstances":{"codex":{"driver":"codex","enabled":false,"config":{}}}}', + ); + + const settings = yield* serverSettings.getSettings; + + assert.equal(settings.textGenerationModelSelection.instanceId, "claudeAgent"); + }).pipe(Effect.provide(makeServerSettingsLayer())), + ); + it.effect("keeps unused providers disabled in existing sparse settings files", () => Effect.gen(function* () { const serverConfig = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 5f2550534883..84e978320a30 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -21,6 +21,7 @@ import { type UsageLimitSourceConfig, ProviderDriverKind, ProviderInstanceId, + resolveProviderInstanceEnabled, ServerSettings, ServerSettingsError, type ServerSettingsPatch, @@ -321,7 +322,13 @@ function resolveTextGenerationProvider(settings: ServerSettings): ServerSettings } function fallbackTextGenerationProvider(settings: ServerSettings): ServerSettings { - const fallbackEntry = Object.entries(settings.providers).find(([, provider]) => provider.enabled); + // Same precedence as isModelSelectionProviderEnabled: an explicit provider + // instance wins over the legacy providers map, which decodes to defaults + // (codex enabled) when the Providers UI has only written providerInstances. + const fallbackEntry = Object.entries(settings.providers).find(([driver, provider]) => { + const instance = settings.providerInstances[ProviderInstanceId.make(driver)]; + return instance === undefined ? provider.enabled : resolveProviderInstanceEnabled(instance); + }); const fallback = fallbackEntry ? ProviderDriverKind.make(fallbackEntry[0]) : undefined; if (!fallback) { return settings; From e0adcc8a24db604c522282edea2823e3ef7a2bc6 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:56:31 -0700 Subject: [PATCH 216/320] fix(server): capture checkpoints before refreshing PR status (#10347) Co-authored-by: Claude Fable 5.1 --- .../Layers/CheckpointReactor.test.ts | 46 ++++++++++++++++++- .../orchestration/Layers/CheckpointReactor.ts | 21 ++++++++- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1dae23cdccb0..9e141600713f 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -302,6 +302,7 @@ describe("CheckpointReactor", () => { readonly providerName?: ProviderDriverKind; readonly gitStatusRefreshCalls?: Array; readonly pullRequestRefreshCalls?: Array; + readonly pullRequestRefresh?: Effect.Effect; }) { const cwd = createGitRepository(); if (options?.initializeGit === false) { @@ -357,7 +358,7 @@ describe("CheckpointReactor", () => { refreshPullRequestStatus: (cwd: string) => Effect.sync(() => { options?.pullRequestRefreshCalls?.push(cwd); - }).pipe(Effect.as(null)), + }).pipe(Effect.andThen(options?.pullRequestRefresh ?? Effect.void), Effect.as(null)), streamStatus: () => Stream.empty, }); @@ -879,6 +880,49 @@ describe("CheckpointReactor", () => { expect(pullRequestRefreshCalls).toEqual([harness.cwd]); }); + effectIt.effect("captures files while the pull request lookup is still pending", () => + Effect.gen(function* () { + const lookupStarted = yield* Deferred.make(); + const finishLookup = yield* Deferred.make(); + const harness = yield* Effect.promise(() => + createHarness({ + seedFilesystemCheckpoints: false, + threadBranch: "t3code/feature", + localStatusRefName: "t3code/feature", + pullRequestRefresh: Deferred.succeed(lookupStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishLookup)), + ), + }), + ); + NodeFS.writeFileSync(NodePath.join(harness.cwd, "README.md"), "completed turn\n"); + harness.provider.emit({ + type: "turn.completed", + eventId: EventId.make("evt-turn-completed-slow-pr"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + threadId: ThreadId.make("thread-1"), + turnId: asTurnId("turn-slow-pr"), + payload: { state: "completed" }, + }); + + yield* Deferred.await(lookupStarted); + yield* Effect.gen(function* () { + expect(yield* harness.nextReceipt).toMatchObject({ + type: "checkpoint.diff.finalized", + turnId: "turn-slow-pr", + }); + expect( + gitShowFileAtRef( + harness.cwd, + checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1), + "README.md", + ), + ).toBe("completed turn\n"); + }).pipe(Effect.ensuring(Deferred.succeed(finishLookup, undefined))); + yield* Effect.promise(harness.drain); + }), + ); + it("re-asks for the pull request after adopting a drifted checkout", async () => { const pullRequestRefreshCalls: string[] = []; const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.ts index 0331b0141fb3..eb77348180ff 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.ts @@ -605,6 +605,23 @@ const make = Effect.gen(function* () { ); }); + // Refreshing git status ends in a remote PR lookup under the vcs status + // write lock. Run it on its own worker so file capture for this turn (and + // checkpoints for other threads) never wait behind that network call. + const statusRefreshWorker = yield* makeDrainableWorker( + (event: Extract) => + refreshLocalGitStatusFromTurnCompletion(event).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("failed to refresh git status after turn completion", { + threadId: event.threadId, + cause: Cause.pretty(cause), + }), + ), + ), + ); + const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn( "ensurePreTurnBaselineFromDomainTurnStart", )(function* ( @@ -853,7 +870,7 @@ const make = Effect.gen(function* () { const isTrackedTurn = sameId(startedTurnId, turnId); if (isTrackedTurn) startedTurns.delete(event.threadId); if (event.type === "turn.completed") { - yield* refreshLocalGitStatusFromTurnCompletion(event); + yield* statusRefreshWorker.enqueue(event); } if ( turnId !== null && @@ -944,7 +961,7 @@ const make = Effect.gen(function* () { return { start, - drain: worker.drain, + drain: worker.drain.pipe(Effect.andThen(statusRefreshWorker.drain)), } satisfies CheckpointReactorShape; }); From bccad270466a039e254167b1b4da06e344d750a4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:56:35 -0700 Subject: [PATCH 217/320] fix(web): keep manual panel choices during a turn (#10113) Co-authored-by: Claude Fable 5.1 --- .../web/src/components/ChatView.logic.test.ts | 148 +++++++++++-- apps/web/src/components/ChatView.logic.ts | 28 ++- apps/web/src/components/ChatView.tsx | 149 ++++++------- apps/web/src/rightPanelStore.test.ts | 107 +++++++++- apps/web/src/rightPanelStore.ts | 195 ++++++++++++------ 5 files changed, 455 insertions(+), 172 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 820d431db4f0..f26f40dd2cdb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -14,7 +14,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test" import type { Thread, ThreadShell, TurnDiffSummary } from "../types"; import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "../providerInstances"; import type { CodexArtifactTemplate } from "@t3tools/client-runtime/codex-artifact-templates"; -import type { RightPanelSurface } from "../rightPanelStore"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + type RightPanelSurface, + pullRequestSurface, + selectActiveRightPanelSurface, + useRightPanelStore, +} from "../rightPanelStore"; +import { + selectThreadPreviewMiniPlayer, + usePreviewMiniPlayerStore, +} from "../previewMiniPlayerStore"; import { MAX_HIDDEN_MOUNTED_PREVIEW_THREADS, MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, @@ -39,6 +49,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftPromotionNavigationTarget, + observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, @@ -95,6 +106,37 @@ describe("agent browser close confirmation", () => { }); describe("floating browser preview", () => { + it("keeps agent preview intent when a user selects its browser tab and then switches away", () => { + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); + usePreviewMiniPlayerStore.setState({ byThreadKey: {} }); + const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1")); + const panels = useRightPanelStore.getState(); + const revision = panels.getUserActionRevision(ref); + usePreviewMiniPlayerStore.getState().open(ref, "agent-tab"); + panels.reconcileBrowserSurfaces(ref, ["agent-tab"]); + const intent = selectThreadPreviewMiniPlayer( + usePreviewMiniPlayerStore.getState().byThreadKey, + ref, + ); + const isFloating = () => + shouldRenderPreviewMiniPlayer( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref) + ?.tabId ?? null, + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref), + ); + + panels.openProactive(ref, { id: "diff", kind: "diff" }, revision); + expect(isFloating()).toBe(true); + panels.activateSurface(ref, "browser:agent-tab"); + expect(isFloating()).toBe(false); + expect(panels.openProactive(ref, { id: "diff", kind: "diff" }, revision)).toBe(false); + panels.open(ref, "diff"); + expect(isFloating()).toBe(true); + expect( + selectThreadPreviewMiniPlayer(usePreviewMiniPlayerStore.getState().byThreadKey, ref), + ).toBe(intent); + }); + it("only hides the duplicate while the same browser is rendered in the panel", () => { expect(shouldRenderPreviewMiniPlayer(null, null)).toBe(false); expect( @@ -116,6 +158,90 @@ describe("floating browser preview", () => { }); describe("proactive panels", () => { + it("keeps a manual PR selection made after following a replacement while loading", () => { + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); + const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1")); + const panels = useRightPanelStore.getState(); + const oldPr = pullRequestSurface({ + projectId: "project-1", + repository: "owner/repo", + number: 1, + }); + const replacement = pullRequestSurface({ ...oldPr, number: 2 }); + const turnId = TurnId.make("turn-1"); + panels.openPullRequest(ref, oldPr); + const loading = observeProactivePanelUserChoice(null, { + threadKey: "env-1:thread-1", + runningTurnId: turnId, + userActionRevision: panels.getUserActionRevision(ref), + }); + expect(panels.openProactive(ref, replacement, loading.userActionRevision)).toBe(true); + + panels.activateSurface(ref, oldPr.id); + const loaded = observeProactivePanelUserChoice(loading, { + threadKey: loading.threadKey, + runningTurnId: turnId, + userActionRevision: panels.getUserActionRevision(ref), + }); + expect(panels.openProactive(ref, replacement, loaded.userActionRevision)).toBe(false); + expect(selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref)).toEqual( + oldPr, + ); + expect(shouldOpenProactivePullRequest(loaded.targetKey, "owner/repo:2")).toBe(false); + expect( + shouldOpenProactiveTurnDiff({ + previousRunningTurnId: loaded.runningTurnId, + runningTurnId: null, + settledTurnId: turnId, + turnCompleted: true, + }), + ).toBe(false); + }); + + it.each(["idle", "loading", "observed"] as const)( + "captures a new turn's choice once with initial state %s", + (initialState) => { + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); + const ref = scopeThreadRef(EnvironmentId.make("env-1"), ThreadId.make("thread-1")); + const panels = useRightPanelStore.getState(); + const firstTurn = TurnId.make("turn-1"); + const nextTurn = TurnId.make("turn-2"); + const initial = observeProactivePanelUserChoice(null, { + threadKey: "env-1:thread-1", + runningTurnId: initialState === "idle" ? null : firstTurn, + userActionRevision: panels.getUserActionRevision(ref), + }); + panels.openFile(ref, "src/first.ts"); + const loadingNextTurn = observeProactivePanelUserChoice( + { + ...initial, + ...(initialState === "observed" ? { runningTurnId: firstTurn, targetKey: null } : {}), + }, + { + threadKey: initial.threadKey, + runningTurnId: nextTurn, + userActionRevision: panels.getUserActionRevision(ref), + }, + ); + expect( + panels.openProactive(ref, { id: "diff", kind: "diff" }, loadingNextTurn.userActionRevision), + ).toBe(true); + + panels.openFile(ref, "src/second.ts"); + const loaded = observeProactivePanelUserChoice(loadingNextTurn, { + threadKey: initial.threadKey, + runningTurnId: nextTurn, + userActionRevision: panels.getUserActionRevision(ref), + }); + expect( + panels.openProactive(ref, { id: "diff", kind: "diff" }, loaded.userActionRevision), + ).toBe(false); + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, ref)?.id, + ).toBe("file:src/second.ts"); + }, + ); + it("opens a pull request only after a newly observed link appears", () => { expect(shouldOpenProactivePullRequest(undefined, "project:repo:42")).toBe(false); expect(shouldOpenProactivePullRequest(null, "project:repo:42")).toBe(true); @@ -173,14 +299,12 @@ describe("proactive panels", () => { resolveProactiveTurnDiffAction({ checkpoint: changedCheckpoint, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe("open"); expect( resolveProactiveTurnDiffAction({ checkpoint: unchangedCheckpoint, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe("ignore"); }); @@ -199,39 +323,21 @@ describe("proactive panels", () => { resolveProactiveTurnDiffAction({ checkpoint: undefined, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe("defer"); expect( resolveProactiveTurnDiffAction({ checkpoint: missingCheckpoint, isGitRepo: true, - activeSurfaceKind: null, }), ).toBe("defer"); expect( resolveProactiveTurnDiffAction({ checkpoint: changedCheckpoint, isGitRepo: undefined, - activeSurfaceKind: null, }), ).toBe("defer"); }); - - it("keeps an active pull request above a completed turn diff", () => { - const changedCheckpoint = { - status: "ready", - files: [{ path: "src/app.ts", kind: "modified", additions: 1, deletions: 0 }], - } satisfies Pick; - - expect( - resolveProactiveTurnDiffAction({ - checkpoint: changedCheckpoint, - isGitRepo: true, - activeSurfaceKind: "pull-request", - }), - ).toBe("ignore"); - }); }); describe("toolGroupConsumesUpwardNavigation", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 1faeb74c9863..08043bd1c6d9 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -106,6 +106,32 @@ export function shouldOpenProactivePullRequest( return previousTargetKey !== undefined && targetKey !== null && targetKey !== previousTargetKey; } +interface ProactivePanelObservation { + threadKey: string; + runningTurnId: TurnId | null | undefined; + targetKey: string | null | undefined; + userActionTurnId: TurnId | null; + userActionRevision: number; +} + +/** Capture user intent before loading or metadata writes can defer panel activation. */ +export function observeProactivePanelUserChoice( + previous: ProactivePanelObservation | null, + input: { threadKey: string; runningTurnId: TurnId | null; userActionRevision: number }, +): ProactivePanelObservation { + const sameThread = previous?.threadKey === input.threadKey; + const newTurn = + sameThread && input.runningTurnId !== null && input.runningTurnId !== previous.userActionTurnId; + return { + threadKey: input.threadKey, + runningTurnId: sameThread ? previous.runningTurnId : undefined, + targetKey: sameThread ? previous.targetKey : undefined, + userActionTurnId: input.runningTurnId ?? (sameThread ? previous.userActionTurnId : null), + userActionRevision: + !sameThread || newTurn ? input.userActionRevision : previous.userActionRevision, + }; +} + export function shouldOpenProactiveTurnDiff(input: { previousRunningTurnId: TurnId | null | undefined; runningTurnId: TurnId | null; @@ -124,9 +150,7 @@ export function shouldOpenProactiveTurnDiff(input: { export function resolveProactiveTurnDiffAction(input: { checkpoint: Pick | undefined; isGitRepo: boolean | undefined; - activeSurfaceKind: RightPanelSurface["kind"] | null; }): "defer" | "ignore" | "open" { - if (input.activeSurfaceKind === "pull-request") return "ignore"; if (input.checkpoint === undefined || input.checkpoint.status === "missing") return "defer"; if (input.isGitRepo === undefined) return "defer"; if ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3974837f5f21..e63c77c89e76 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -161,6 +161,7 @@ import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; import { RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY } from "../rightPanelLayout"; import { + pullRequestSurface, selectActiveRightPanel, selectActiveRightPanelSurface, selectThreadRightPanelState, @@ -396,6 +397,7 @@ import { resolveComposerInteractionMode, resolveComposerProviderSelection, resolveDraftHeroState, + observeProactivePanelUserChoice, resolveProactiveTurnDiffAction, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, @@ -4060,23 +4062,6 @@ export default function ChatView(props: ChatViewProps) { const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); - const openSurface = selectActiveRightPanelSurface( - useRightPanelStore.getState().byThreadKey, - activeThreadRef, - ); - if ( - openSurface?.kind === "pull-request" && - persistedLinkedThreadPullRequest !== null && - openSurface.projectId === persistedLinkedThreadPullRequest.projectId && - openSurface.repository.toLowerCase() === - persistedLinkedThreadPullRequest.repository.toLowerCase() && - openSurface.number === persistedLinkedThreadPullRequest.number - ) { - useRightPanelStore - .getState() - .openPullRequest(activeThreadRef, replacementLinkedThreadPullRequest); - } - threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; const result = await updateThreadMetadata({ @@ -4102,7 +4087,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey, activeThreadRef, isServerThread, - persistedLinkedThreadPullRequest, replacementLinkedThreadPullRequest, updateThreadMetadata, ]); @@ -4124,29 +4108,47 @@ export default function ChatView(props: ChatViewProps) { }, [activeProject, activeProjectRepository, activeThreadRef, supportsPullRequests], ); - const proactiveTurnObservationRef = useRef<{ - threadKey: string; - runningTurnId: TurnId | null; - } | null>(null); - const proactivePullRequestObservationRef = useRef<{ - threadKey: string; - targetKey: string | null; - } | null>(null); + const proactivePanelObservationRef = useRef | null>(null); useEffect(() => { if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { - proactiveTurnObservationRef.current = null; + proactivePanelObservationRef.current = null; return; } - if (!clientSettingsHydrated || threadDetailLoading) { - return; + const panels = useRightPanelStore.getState(); + const observation = observeProactivePanelUserChoice(proactivePanelObservationRef.current, { + threadKey: activeThreadKey, + runningTurnId: activeRunningTurnId, + userActionRevision: panels.getUserActionRevision(activeThreadRef), + }); + proactivePanelObservationRef.current = observation; + const { + runningTurnId: previousRunningTurnId, + targetKey: previousTargetKey, + userActionRevision, + } = observation; + const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef); + const followSelectedPullRequest = + replacementLinkedThreadPullRequest !== null && + openSurface?.kind === "pull-request" && + persistedLinkedThreadPullRequest !== null && + openSurface.projectId === persistedLinkedThreadPullRequest.projectId && + openSurface.repository.toLowerCase() === + persistedLinkedThreadPullRequest.repository.toLowerCase() && + openSurface.number === persistedLinkedThreadPullRequest.number; + // Following the selected linked PR does not open an unrelated panel, so it + // remains available with proactive panels off. It still respects a later choice. + if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) { + panels.openProactive( + activeThreadRef, + pullRequestSurface(replacementLinkedThreadPullRequest), + userActionRevision, + ); } + if (!clientSettingsHydrated || threadDetailLoading) return; - const previousObservation = proactiveTurnObservationRef.current; - const observingSameThread = previousObservation?.threadKey === activeThreadKey; - const previousRunningTurnId = observingSameThread - ? previousObservation.runningTurnId - : undefined; const settledTurnId = latestTurnSettled ? (activeLatestTurn?.turnId ?? null) : null; const newlyCompletedTurnId = shouldOpenProactiveTurnDiff({ previousRunningTurnId, @@ -4156,8 +4158,8 @@ export default function ChatView(props: ChatViewProps) { }) ? settledTurnId : null; - const eligibleCompletion = - settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyCompletedTurnId !== null; + const proactivePanelsEnabled = settings.proactivePanelsEnabled && !shouldUseRightPanelSheet; + const eligibleCompletion = proactivePanelsEnabled && newlyCompletedTurnId !== null; const completedCheckpoint = eligibleCompletion ? activeThread?.checkpoints.find((checkpoint) => checkpoint.turnId === newlyCompletedTurnId) : undefined; @@ -4165,17 +4167,36 @@ export default function ChatView(props: ChatViewProps) { ? resolveProactiveTurnDiffAction({ checkpoint: completedCheckpoint, isGitRepo: gitStatusQuery.data?.isRepo, - activeSurfaceKind: activeRightPanelSurface?.kind ?? null, }) : "ignore"; - proactiveTurnObservationRef.current = { - threadKey: activeThreadKey, + const eligibleLink = + proactivePanelsEnabled && + shouldOpenProactivePullRequest(previousTargetKey, linkedThreadPullRequestKey); + const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown; + proactivePanelObservationRef.current = { + ...observation, runningTurnId: diffAction === "defer" ? (previousRunningTurnId ?? null) : activeRunningTurnId, + targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey, }; - if (diffAction !== "open" || newlyCompletedTurnId === null) return; + if ( + !followSelectedPullRequest && + eligibleLink && + pullRequestsCapabilityKnown && + supportsPullRequests && + linkedThreadPullRequest !== null + ) { + panels.openProactive( + activeThreadRef, + pullRequestSurface(linkedThreadPullRequest), + userActionRevision, + ); + } + if (diffAction !== "open" || newlyCompletedTurnId === null) return; + if (!panels.openProactive(activeThreadRef, { id: "diff", kind: "diff" }, userActionRevision)) { + return; + } useDiffPanelStore.getState().selectTurn(activeThreadRef, newlyCompletedTurnId); - useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }, [ activeThread?.checkpoints, @@ -4184,56 +4205,16 @@ export default function ChatView(props: ChatViewProps) { activeRunningTurnId, activeThreadKey, activeThreadRef, - activeRightPanelSurface?.kind, clientSettingsHydrated, gitStatusQuery.data?.isRepo, isServerThread, latestTurnSettled, - onDiffPanelOpen, - settings.proactivePanelsEnabled, - shouldUseRightPanelSheet, - threadDetailLoading, - ]); - - useEffect(() => { - if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { - proactivePullRequestObservationRef.current = null; - return; - } - if (!clientSettingsHydrated || threadDetailLoading) { - return; - } - - const previousObservation = proactivePullRequestObservationRef.current; - const observingSameThread = previousObservation?.threadKey === activeThreadKey; - const previousTargetKey = observingSameThread ? previousObservation.targetKey : undefined; - const newlyLinkedPullRequest = shouldOpenProactivePullRequest( - previousTargetKey, - linkedThreadPullRequestKey, - ); - const eligibleLink = - settings.proactivePanelsEnabled && !shouldUseRightPanelSheet && newlyLinkedPullRequest; - const shouldOpenLink = - eligibleLink && - pullRequestsCapabilityKnown && - supportsPullRequests && - linkedThreadPullRequest !== null; - const shouldDeferLink = eligibleLink && !pullRequestsCapabilityKnown; - proactivePullRequestObservationRef.current = { - threadKey: activeThreadKey, - targetKey: shouldDeferLink ? (previousTargetKey ?? null) : linkedThreadPullRequestKey, - }; - if (!shouldOpenLink || linkedThreadPullRequest === null) return; - - useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); - }, [ - activeThreadKey, - activeThreadRef, - clientSettingsHydrated, - isServerThread, linkedThreadPullRequest, linkedThreadPullRequestKey, + onDiffPanelOpen, + persistedLinkedThreadPullRequest, pullRequestsCapabilityKnown, + replacementLinkedThreadPullRequest, settings.proactivePanelsEnabled, shouldUseRightPanelSheet, supportsPullRequests, diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index 7f981f2bcda0..4336aa2a35ec 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it } from "vite-plus/test"; import { migratePersistedRightPanelState, + pullRequestSurface, pullRequestSurfaceId, selectActiveRightPanel, selectActiveRightPanelSurface, @@ -16,10 +17,114 @@ const refA = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-A")) const refB = scopeThreadRef("env-1" as EnvironmentId, ThreadId.make("thread-B")); beforeEach(() => { - useRightPanelStore.setState({ byThreadKey: {} }); + useRightPanelStore.setState({ byThreadKey: {}, userActionRevisionByThreadKey: {} }); }); describe("rightPanelStore", () => { + const completedDiff = { id: "diff", kind: "diff" } as const; + const linkedPullRequest = pullRequestSurface({ + projectId: "project-a", + repository: "pingdotgg/t3code", + number: 42, + }); + + it.each(["diff-first", "pull-request-first"])( + "keeps the linked pull request above the completed diff with %s delivery", + (order) => { + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + const requests = + order === "diff-first" + ? [completedDiff, linkedPullRequest] + : [linkedPullRequest, completedDiff]; + for (const surface of requests) store.openProactive(refA, surface, revision); + + expect( + selectActiveRightPanelSurface(useRightPanelStore.getState().byThreadKey, refA), + ).toEqual(linkedPullRequest); + + store.open(refA, "diff"); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }, + ); + + it.each([ + { choice: "file", choose: () => useRightPanelStore.getState().openFile(refA, "src/app.ts") }, + { + choice: "pull request", + choose: () => + useRightPanelStore.getState().openPullRequest(refA, { ...linkedPullRequest, number: 41 }), + }, + { choice: "browser", choose: () => useRightPanelStore.getState().openBrowser(refA, "tab-a") }, + { + choice: "terminal", + choose: () => useRightPanelStore.getState().openTerminal(refA, "term-1"), + }, + { + choice: "same tab", + choose: () => useRightPanelStore.getState().activateSurface(refA, "diff"), + }, + { choice: "hide", choose: () => useRightPanelStore.getState().close(refA) }, + { choice: "toggle", choose: () => useRightPanelStore.getState().toggle(refA, "diff") }, + { choice: "close all", choose: () => useRightPanelStore.getState().closeAllSurfaces(refA) }, + { + choice: "terminal close", + choose: () => { + const store = useRightPanelStore.getState(); + store.openTerminal(refA, "term-1"); + store.closeTerminal(refA, "terminal:term-1", "term-1"); + }, + }, + ])("keeps a later $choice choice when automatic requests arrive", ({ choose }) => { + const store = useRightPanelStore.getState(); + store.open(refA, "diff"); + const revision = store.getUserActionRevision(refA); + choose(); + const chosen = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(false); + expect(store.openProactive(refA, linkedPullRequest, revision)).toBe(false); + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toBe( + chosen, + ); + }); + + it("allows automatic panels for a later turn after a manual choice", () => { + const store = useRightPanelStore.getState(); + const firstTurnRevision = store.getUserActionRevision(refA); + store.openFile(refA, "src/app.ts"); + expect(store.openProactive(refA, completedDiff, firstTurnRevision)).toBe(false); + + const nextTurnRevision = store.getUserActionRevision(refA); + expect(store.openProactive(refA, completedDiff, nextTurnRevision)).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }); + + it("keeps manual choices scoped to their thread and environment", () => { + const otherEnvironment = scopeThreadRef("env-2" as EnvironmentId, refA.threadId); + const store = useRightPanelStore.getState(); + const revision = store.getUserActionRevision(refA); + store.openFile(refB, "src/app.ts"); + store.openFile(otherEnvironment, "src/app.ts"); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refB)).toBe("file"); + expect( + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, otherEnvironment), + ).toBe("file"); + }); + + it("does not treat resource reconciliation as a manual choice", () => { + const store = useRightPanelStore.getState(); + store.openFile(refA, "src/app.ts"); + const revision = store.getUserActionRevision(refA); + store.reconcileBrowserSurfaces(refA, ["agent-browser"]); + store.reconcileFileSurfaces(refA, false); + + expect(store.openProactive(refA, completedDiff, revision)).toBe(true); + expect(selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, refA)).toBe("diff"); + }); + it("drops the legacy singleton terminal surface during migration", () => { expect( migratePersistedRightPanelState({ diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 1173cae3ef38..acf673b042bd 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -88,6 +88,18 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; + /** Session-only count of user panel choices per thread. Automatic updates do not advance it. */ + userActionRevisionByThreadKey: Record; + getUserActionRevision: (ref: ScopedThreadRef) => number; + /** + * Open a surface on behalf of the app, not the user. Refused when the user + * made a panel choice after `expectedUserActionRevision` was read. + */ + openProactive: ( + ref: ScopedThreadRef, + surface: Extract, + expectedUserActionRevision: number, + ) => boolean; open: ( ref: ScopedThreadRef, kind: Exclude, @@ -193,7 +205,7 @@ export function pullRequestSurfaceId(target: { return `pull-request:${scope}${encodeURIComponent(target.projectId)}:${encodeURIComponent(target.repository)}:${target.number}`; } -function pullRequestSurface(target: { +export function pullRequestSurface(target: { environmentId?: string; projectId: string; repository: string; @@ -237,6 +249,29 @@ const updateThread = ( return { ...byThreadKey, [threadKey]: next }; }; +// Every store action is a user choice unless it goes through `automaticUpdate`. +// Only `openProactive` and resource reconciliation are automatic, so a new +// action counts as a user choice by default. +const automaticUpdate = ( + state: RightPanelStoreState, + threadKey: string, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, +): Partial => ({ + byThreadKey: updateThread(state.byThreadKey, threadKey, updater), +}); + +const userAction = ( + state: RightPanelStoreState, + threadKey: string, + updater: (current: ThreadRightPanelState) => ThreadRightPanelState, +): Partial => ({ + byThreadKey: updateThread(state.byThreadKey, threadKey, updater), + userActionRevisionByThreadKey: { + ...state.userActionRevisionByThreadKey, + [threadKey]: (state.userActionRevisionByThreadKey[threadKey] ?? 0) + 1, + }, +}); + function normalizeRevealLine(line: number | undefined): number | null { if (line === undefined || !Number.isFinite(line)) return null; return Math.max(1, Math.trunc(line)); @@ -359,37 +394,62 @@ export function migratePersistedRightPanelState(persistedState: unknown): { export const useRightPanelStore = create()( persist( - (set) => ({ + (set, get) => ({ byThreadKey: {}, + userActionRevisionByThreadKey: {}, + getUserActionRevision: (ref) => + get().userActionRevisionByThreadKey[scopedThreadKey(ref)] ?? 0, + openProactive: (ref, surface, expectedUserActionRevision) => { + let opened = false; + set((state) => { + const threadKey = scopedThreadKey(ref); + if ( + (state.userActionRevisionByThreadKey[threadKey] ?? 0) !== expectedUserActionRevision + ) { + return state; + } + // A linked PR takes priority over a completed-turn diff. Manual actions + // always apply, and later user choices reject both proactive requests. + if ( + surface.kind === "diff" && + selectActiveRightPanel(state.byThreadKey, ref) === "pull-request" + ) { + return state; + } + opened = true; + return automaticUpdate(state, threadKey, (current) => upsertSurface(current, surface)); + }); + return opened; + }, open: (ref, kind) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { if (kind === "preview") { const existing = current.surfaces.find((surface) => surface.kind === "preview"); return upsertSurface(current, existing ?? browserSurface(null)); } return upsertSurface(current, singletonSurface(kind)); }), - })), + ), openBrowser: (ref, tabId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const surface = browserSurface(tabId); const withoutPlaceholder = tabId ? current.surfaces.filter((entry) => entry.id !== "browser:new") : current.surfaces; return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), - })), + ), openPullRequest: (ref, target) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { return upsertSurface(current, pullRequestSurface(target)); }), - })), + ), openFile: (ref, relativePath, line) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); @@ -413,10 +473,10 @@ export const useRightPanelStore = create()( : [...withoutStandaloneExplorer, surface], }; }), - })), + ), openAttachment: (ref, attachment) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const withoutStandaloneExplorer = current.surfaces.filter( (surface) => surface.kind !== "files", ); @@ -425,16 +485,16 @@ export const useRightPanelStore = create()( attachmentSurface(attachment), ); }), - })), + ), openTerminal: (ref, terminalId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => upsertSurface(current, terminalSurface(terminalId)), ), - })), + ), splitTerminal: (ref, surfaceId, terminalId, direction = "horizontal") => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ ...current, isOpen: true, activeSurfaceId: surfaceId, @@ -451,10 +511,10 @@ export const useRightPanelStore = create()( }; }), })), - })), + ), activateTerminal: (ref, surfaceId, terminalId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ ...current, activeSurfaceId: surfaceId, surfaces: current.surfaces.map((surface) => @@ -465,10 +525,10 @@ export const useRightPanelStore = create()( : surface, ), })), - })), + ), closeTerminal: (ref, surfaceId, terminalId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find( (entry) => entry.id === surfaceId && entry.kind === "terminal", ); @@ -504,18 +564,18 @@ export const useRightPanelStore = create()( ), }; }), - })), + ), activateSurface: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.surfaces.some((surface) => surface.id === surfaceId) ? { ...current, isOpen: true, activeSurfaceId: surfaceId } : current, ), - })), + ), closeSurface: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0) return current; const surfaces = current.surfaces.filter((surface) => surface.id !== surfaceId); @@ -530,10 +590,10 @@ export const useRightPanelStore = create()( activeSurfaceId: fallback?.id ?? null, }; }), - })), + ), closeOtherSurfaces: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const surface = current.surfaces.find((entry) => entry.id === surfaceId); if (!surface || current.surfaces.length === 1) return current; return { @@ -543,10 +603,10 @@ export const useRightPanelStore = create()( activeSurfaceId: surface.id, }; }), - })), + ), closeSurfacesToRight: (ref, surfaceId) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const index = current.surfaces.findIndex((surface) => surface.id === surfaceId); if (index < 0 || index === current.surfaces.length - 1) return current; const surfaces = current.surfaces.slice(0, index + 1); @@ -559,18 +619,18 @@ export const useRightPanelStore = create()( activeSurfaceId: activeStillExists ? current.activeSurfaceId : surfaceId, }; }), - })), + ), closeAllSurfaces: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.surfaces.length === 0 ? current : { ...current, isOpen: false, surfaces: [], activeSurfaceId: null }, ), - })), + ), reconcileBrowserSurfaces: (ref, tabIds) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { const validIds = new Set(tabIds.map((tabId) => `browser:${tabId}`)); const nonBrowser = current.surfaces.filter((surface) => surface.kind !== "preview"); const existingBrowser = current.surfaces.filter( @@ -596,10 +656,10 @@ export const useRightPanelStore = create()( : (fallbackBrowser?.id ?? surfaces[0]?.id ?? null), }; }), - })), + ), reconcileFileSurfaces: (ref, workspaceAvailable) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + automaticUpdate(state, scopedThreadKey(ref), (current) => { if (workspaceAvailable) return current; const surfaces = current.surfaces.filter( (surface) => @@ -619,29 +679,29 @@ export const useRightPanelStore = create()( : (surfaces.at(-1)?.id ?? null), }; }), - })), + ), show: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.isOpen ? current : { ...current, isOpen: true }, ), - })), + ), close: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => + set((state) => + userAction(state, scopedThreadKey(ref), (current) => current.isOpen ? { ...current, isOpen: false } : current, ), - })), + ), toggleVisibility: (ref) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => ({ + set((state) => + userAction(state, scopedThreadKey(ref), (current) => ({ ...current, isOpen: !current.isOpen, })), - })), + ), toggle: (ref, kind) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + set((state) => + userAction(state, scopedThreadKey(ref), (current) => { const active = current.surfaces.find( (surface) => surface.id === current.activeSurfaceId, ); @@ -654,13 +714,20 @@ export const useRightPanelStore = create()( } return upsertSurface(current, singletonSurface(kind)); }), - })), + ), removeThread: (ref) => set((state) => { const threadKey = scopedThreadKey(ref); - if (!(threadKey in state.byThreadKey)) return state; + if ( + !(threadKey in state.byThreadKey) && + !(threadKey in state.userActionRevisionByThreadKey) + ) { + return state; + } const { [threadKey]: _removed, ...rest } = state.byThreadKey; - return { byThreadKey: rest }; + const { [threadKey]: _revision, ...userActionRevisionByThreadKey } = + state.userActionRevisionByThreadKey; + return { byThreadKey: rest, userActionRevisionByThreadKey }; }), }), { From e63ddb48e2fd23854a0b4a480b32cbf33e601981 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:56:39 -0700 Subject: [PATCH 218/320] fix(threads): keep completed requests closed across clients (#10123) Co-authored-by: Claude Fable 5.1 --- apps/mobile/src/lib/threadActivity.test.ts | 122 +---- apps/mobile/src/lib/threadActivity.ts | 226 +------- .../src/state/use-selected-thread-requests.ts | 19 +- apps/web/src/components/ChatView.tsx | 11 +- apps/web/src/session-logic.test.ts | 397 -------------- apps/web/src/session-logic.ts | 231 +------- packages/client-runtime/package.json | 4 + .../src/pendingRequests.test.ts | 497 ++++++++++++++++++ .../client-runtime/src/pendingRequests.ts | 189 +++++++ 9 files changed, 711 insertions(+), 985 deletions(-) create mode 100644 packages/client-runtime/src/pendingRequests.test.ts create mode 100644 packages/client-runtime/src/pendingRequests.ts diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 7d6cc39ea616..8e31719b43dc 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -1,3 +1,4 @@ +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { describe, expect, it } from "vite-plus/test"; import { codexFeedbackMessage } from "@t3tools/client-runtime/state/threads"; @@ -16,8 +17,6 @@ import { agentSpawnSummary, buildPendingUserInputAnswers, buildThreadFeed, - derivePendingApprovals, - derivePendingUserInputs, deriveThreadFeedPresentation, isPendingUserInputOptionSelected, setPendingUserInputCustomAnswer, @@ -107,7 +106,7 @@ describe("pending user input answers", () => { createdAt: "2026-09-03T00:00:00.000Z", payload: { requestId: "async-1", responseMode: "message", questions: [question] }, }); - const questions = derivePendingUserInputs([requested])[0]?.questions; + const questions = derivePendingRequests([requested]).userInputs[0]?.questions; expect(questions).toEqual([question]); expect(buildPendingUserInputAnswers(questions!, { "0": { customAnswer: "Example" } })).toEqual({ "0": "Example", @@ -126,7 +125,7 @@ describe("pending user input answers", () => { }, }); - expect(derivePendingUserInputs([requested])).toEqual([ + expect(derivePendingRequests([requested]).userInputs).toEqual([ { requestId: "interaction_1", createdAt: requested.createdAt, @@ -265,121 +264,6 @@ describe("pending user input answers", () => { }); }); -describe("pending approvals", () => { - it.each([{}, { requestType: "unknown" }])( - "exposes legacy OpenCode approvals without a known request kind: %j", - (legacyPayload) => { - const requested = makeActivity({ - id: EventId.make("approval-legacy"), - kind: "approval.requested", - summary: "Approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, - }); - - expect(derivePendingApprovals([requested])).toEqual([ - { - requestId: "per-legacy", - requestKind: "command", - createdAt: requested.createdAt, - detail: "*", - }, - ]); - }, - ); - - it.each(["tool_user_input", "auth_tokens_refresh"])( - "does not turn %s into an approval", - (requestType) => { - const activity = makeActivity({ - id: EventId.make("approval-non-approval"), - kind: "approval.requested", - summary: "Approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "not-an-approval", requestType }, - }); - - expect(derivePendingApprovals([activity])).toEqual([]); - }, - ); - - it.each(["approval.resolved", "provider.approval.respond.failed"])( - "removes legacy approvals after %s", - (kind) => { - const requested = makeActivity({ - id: EventId.make("approval-legacy-open"), - kind: "approval.requested", - summary: "Approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "per-legacy", requestType: "unknown" }, - }); - const resolved = makeActivity({ - id: EventId.make("approval-legacy-resolved"), - kind, - summary: "Approval resolved", - createdAt: "2026-08-24T00:00:01.000Z", - payload: { - requestId: "per-legacy", - detail: "Unknown pending permission request: per-legacy", - }, - }); - - expect(derivePendingApprovals([requested, resolved])).toEqual([]); - }, - ); - - it("keeps app access approvals and persistence choices from remote environments", () => { - const options = [ - { decision: "decline", label: "Decline" }, - { decision: "acceptAlways", label: "Always allow Safari" }, - { decision: "accept", label: "Approve" }, - ]; - const activity = makeActivity({ - id: EventId.make("approval-safari"), - kind: "approval.requested", - summary: "App access approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { - requestId: "req-safari", - requestType: "mcp_elicitation_approval", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - }); - - expect(derivePendingApprovals([activity])).toEqual([ - { - requestId: "req-safari", - requestKind: "mcp-elicitation", - createdAt: "2026-08-24T00:00:00.000Z", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - ]); - }); - - it("removes an app access approval after a remote client rejects it", () => { - const requested = makeActivity({ - id: EventId.make("approval-safari-open"), - kind: "approval.requested", - summary: "App access approval requested", - createdAt: "2026-08-24T00:00:00.000Z", - payload: { requestId: "req-safari", requestKind: "mcp-elicitation" }, - }); - const resolved = makeActivity({ - id: EventId.make("approval-safari-resolved"), - kind: "approval.resolved", - summary: "Approval resolved", - createdAt: "2026-08-24T00:00:01.000Z", - payload: { requestId: "req-safari", decision: "decline" }, - }); - - expect(derivePendingApprovals([requested, resolved])).toEqual([]); - }); -}); - function makeActivity( input: Partial & Pick, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index f0ba11a0ccaa..42e0db669a73 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -1,9 +1,8 @@ import { - ApprovalRequestId, - isToolLifecycleItemType, - ProviderApprovalOption, - ProviderRequestKind, -} from "@t3tools/contracts"; + requestKindFromRequestType, + type PendingApproval, +} from "@t3tools/client-runtime/pending-requests"; +import { isToolLifecycleItemType } from "@t3tools/contracts"; import type { OrchestrationLatestTurn, OrchestrationThread, @@ -36,25 +35,8 @@ import { commandProgramName } from "@t3tools/client-runtime/work-log/command-lab import * as Arr from "effect/Array"; import * as Order from "effect/Order"; -import * as Schema from "effect/Schema"; -export interface PendingApproval { - readonly requestId: ApprovalRequestId; - readonly requestKind: ProviderRequestKind; - readonly createdAt: string; - readonly detail?: string; - readonly appName?: string; - readonly options?: ReadonlyArray; -} - -const isProviderRequestKind = Schema.is(ProviderRequestKind); -const isProviderApprovalOption = Schema.is(ProviderApprovalOption); - -export interface PendingUserInput { - readonly requestId: ApprovalRequestId; - readonly createdAt: string; - readonly questions: ReadonlyArray; -} +export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests"; export interface PendingUserInputDraftAnswer { readonly selectedOptionValues?: ReadonlyArray; @@ -276,94 +258,6 @@ export function isContextCompactionActivityGroup( ); } -function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { - switch (requestType) { - case "command_execution_approval": - case "exec_command_approval": - return "command"; - case "file_read_approval": - return "file-read"; - case "file_change_approval": - case "apply_patch_approval": - return "file-change"; - case "mcp_elicitation_approval": - return "mcp-elicitation"; - default: - return null; - } -} - -function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { - const normalized = detail?.toLowerCase(); - if (!normalized) { - return false; - } - return ( - normalized.includes("stale pending approval request") || - normalized.includes("stale pending user-input request") || - normalized.includes("unknown pending approval request") || - normalized.includes("unknown pending permission request") || - normalized.includes("unknown pending user-input request") - ); -} - -function parseApprovalRequestId(value: unknown): ApprovalRequestId | null { - return typeof value === "string" && value.length > 0 ? ApprovalRequestId.make(value) : null; -} - -function parseUserInputQuestions( - payload: Record | null, -): ReadonlyArray | null { - const questions = payload?.questions; - if (!Array.isArray(questions)) { - return null; - } - - const parsed = questions - .map((entry) => { - if (!entry || typeof entry !== "object") return null; - const question = entry as Record; - if ( - typeof question.id !== "string" || - typeof question.header !== "string" || - typeof question.question !== "string" || - !Array.isArray(question.options) - ) { - return null; - } - const options = question.options - .map((option) => { - if (!option || typeof option !== "object") return null; - const record = option as Record; - if (typeof record.label !== "string" || typeof record.description !== "string") { - return null; - } - return { - label: record.label, - description: record.description, - ...(typeof record.value === "string" ? { value: record.value } : {}), - }; - }) - .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0 && question.allowCustomAnswer === false) { - return null; - } - return { - id: question.id, - header: question.header, - question: question.question, - options, - multiSelect: question.multiSelect === true, - ...(typeof question.allowCustomAnswer === "boolean" - ? { allowCustomAnswer: question.allowCustomAnswer } - : {}), - }; - }) - .filter((question): question is UserInputQuestion => question !== null); - - return parsed.length > 0 ? parsed : null; -} - function normalizeDraftAnswer(value: string | undefined): string | null { if (typeof value !== "string") { return null; @@ -2169,116 +2063,6 @@ function liveToolActivitySummary(activity: ThreadFeedActivity, presentTense: boo return activity.detail ?? activity.summary; } -/** - * Sorts activities into lifecycle order. `derivePendingApprovals` and - * `derivePendingUserInputs` both expect this ordering; sorting once and - * passing the result to both avoids re-sorting the full activity history - * per derivation. - */ -export function sortThreadActivities( - activities: ReadonlyArray, -): ReadonlyArray { - return Arr.sort(activities, activityOrder); -} - -export function derivePendingApprovals( - sortedActivities: ReadonlyArray, -): PendingApproval[] { - const openByRequestId = new Map(); - - for (const activity of sortedActivities) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = parseApprovalRequestId(payload?.requestId); - const requestKind = isProviderRequestKind(payload?.requestKind) - ? payload.requestKind - : requestKindFromRequestType(payload?.requestType); - const detail = typeof payload?.detail === "string" ? payload.detail : undefined; - const appName = typeof payload?.appName === "string" ? payload.appName : undefined; - const options = Array.isArray(payload?.options) - ? payload.options.filter(isProviderApprovalOption) - : undefined; - - if ( - activity.kind === "approval.requested" && - requestId && - payload?.requestType !== "tool_user_input" && - payload?.requestType !== "auth_tokens_refresh" - ) { - openByRequestId.set(requestId, { - requestId, - // Older OpenCode requests can have no recognized approval kind. - requestKind: requestKind ?? "command", - createdAt: activity.createdAt, - ...(detail ? { detail } : {}), - ...(appName ? { appName } : {}), - ...(options && options.length > 0 ? { options } : {}), - }); - continue; - } - - if (activity.kind === "approval.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.approval.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - } - } - - return Arr.sortWith([...openByRequestId.values()], (s) => new Date(s.createdAt), Order.Date); -} - -export function derivePendingUserInputs( - sortedActivities: ReadonlyArray, -): PendingUserInput[] { - const openByRequestId = new Map(); - - for (const activity of sortedActivities) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = parseApprovalRequestId(payload?.requestId); - const detail = typeof payload?.detail === "string" ? payload.detail : undefined; - - if (activity.kind === "user-input.requested" && requestId) { - const questions = parseUserInputQuestions(payload); - if (!questions) { - continue; - } - openByRequestId.set(requestId, { - requestId, - createdAt: activity.createdAt, - questions, - }); - continue; - } - - if (activity.kind === "user-input.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.user-input.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - } - } - - return Arr.sortWith(openByRequestId.values(), (s) => new Date(s.createdAt), Order.Date); -} - export function setPendingUserInputCustomAnswer( question: UserInputQuestion, draft: PendingUserInputDraftAnswer | undefined, diff --git a/apps/mobile/src/state/use-selected-thread-requests.ts b/apps/mobile/src/state/use-selected-thread-requests.ts index 6208a806819d..1b5209dec319 100644 --- a/apps/mobile/src/state/use-selected-thread-requests.ts +++ b/apps/mobile/src/state/use-selected-thread-requests.ts @@ -1,3 +1,4 @@ +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { useAtomValue } from "@effect/atom-react"; import { useCallback, useMemo, useState } from "react"; @@ -12,10 +13,7 @@ import { threadEnvironment } from "../state/threads"; import { scopedRequestKey } from "../lib/scopedEntities"; import { buildPendingUserInputAnswers, - derivePendingApprovals, - derivePendingUserInputs, setPendingUserInputCustomAnswer, - sortThreadActivities, togglePendingUserInputOptionSelection, type PendingUserInputDraftAnswer, } from "../lib/threadActivity"; @@ -83,20 +81,11 @@ export function useSelectedThreadRequests() { null, ); - // Sort once; both derivations expect the same lifecycle ordering. - const sortedActivities = useMemo( - () => (selectedThread ? sortThreadActivities(selectedThread.activities) : []), - [selectedThread], - ); - const activePendingApprovals = useMemo( - () => derivePendingApprovals(sortedActivities), - [sortedActivities], + const { approvals: activePendingApprovals, userInputs: activePendingUserInputs } = useMemo( + () => derivePendingRequests(selectedThread?.activities ?? []), + [selectedThread?.activities], ); const activePendingApproval = activePendingApprovals[0] ?? null; - const activePendingUserInputs = useMemo( - () => derivePendingUserInputs(sortedActivities), - [sortedActivities], - ); const activePendingUserInput = activePendingUserInputs[0] ?? null; const activePendingUserInputDrafts = activePendingUserInput && selectedThreadShell diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index e63c77c89e76..3ff390c6640c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -6,6 +6,7 @@ import { isUsageLimitsCommand, } from "@t3tools/shared/usageLimits"; import { usageLimitsBannerItem } from "./chat/ComposerUsageLimits"; +import { derivePendingRequests } from "@t3tools/client-runtime/pending-requests"; import { type AssistantCitation, type ApprovalRequestId, @@ -106,8 +107,6 @@ import { } from "../composer-logic"; import { createMessageAttachmentPreviewProjector, - derivePendingApprovals, - derivePendingUserInputs, derivePhase, deriveTimelineEntriesWithState, deriveActiveWorkStartedAt, @@ -2586,12 +2585,8 @@ export default function ChatView(props: ChatViewProps) { }), [agentSessionLive, threadActivities], ); - const pendingApprovals = useMemo( - () => derivePendingApprovals(threadActivities), - [threadActivities], - ); - const pendingUserInputs = useMemo( - () => derivePendingUserInputs(threadActivities), + const { approvals: pendingApprovals, userInputs: pendingUserInputs } = useMemo( + () => derivePendingRequests(threadActivities), [threadActivities], ); const activePendingUserInput = pendingUserInputs[0] ?? null; diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index f9b888fd1dd1..401934bce5ba 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -13,8 +13,6 @@ import { createMessageAttachmentPreviewProjector, deriveActiveWorkStartedAt, deriveActivePlanState, - derivePendingApprovals, - derivePendingUserInputs, deriveTimelineEntries, deriveTimelineEntriesWithState, deriveWorkLogEntries, @@ -63,401 +61,6 @@ function makeActivity(overrides: { }; } -describe("derivePendingApprovals", () => { - it.each([{}, { requestType: "unknown" }])( - "exposes legacy OpenCode approvals without a known request kind: %j", - (legacyPayload) => { - const requested = makeActivity({ - kind: "approval.requested", - payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, - }); - - expect(derivePendingApprovals([requested])).toEqual([ - { - requestId: "per-legacy", - requestKind: "command", - createdAt: requested.createdAt, - detail: "*", - }, - ]); - }, - ); - - it.each(["tool_user_input", "auth_tokens_refresh"])( - "does not turn %s into an approval", - (requestType) => { - const activity = makeActivity({ - kind: "approval.requested", - payload: { requestId: "not-an-approval", requestType }, - }); - - expect(derivePendingApprovals([activity])).toEqual([]); - }, - ); - - it("tracks open approvals and removes resolved ones", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-1", - requestKind: "command", - detail: "bun run lint", - }, - }), - makeActivity({ - id: "approval-close", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "approval.resolved", - summary: "Approval resolved", - tone: "info", - payload: { requestId: "req-2" }, - }), - makeActivity({ - id: "approval-closed-request", - createdAt: "2026-02-23T00:00:01.500Z", - kind: "approval.requested", - summary: "File-change approval requested", - tone: "approval", - payload: { requestId: "req-2", requestType: "unknown" }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-1", - requestKind: "command", - createdAt: "2026-02-23T00:00:01.000Z", - detail: "bun run lint", - }, - ]); - }); - - it("maps canonical requestType payloads into pending approvals", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-request-type", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-request-type", - requestType: "command_execution_approval", - detail: "pwd", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-request-type", - requestKind: "command", - createdAt: "2026-02-23T00:00:01.000Z", - detail: "pwd", - }, - ]); - }); - - it("keeps app access approvals and persistence choices from remote activities", () => { - const options = [ - { decision: "decline", label: "Decline" }, - { decision: "acceptAlways", label: "Always allow Safari" }, - { decision: "accept", label: "Approve" }, - ]; - const activities = [ - makeActivity({ - kind: "approval.requested", - summary: "App access approval requested", - tone: "approval", - payload: { - requestId: "req-safari", - requestType: "mcp_elicitation_approval", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-safari", - requestKind: "mcp-elicitation", - createdAt: "2026-02-23T00:00:00.000Z", - detail: "Allow ChatGPT to use Safari?", - appName: "Safari", - options, - }, - ]); - }); - - it("derives dynamic tool requests as actionable generic approvals", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-dynamic-tool", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Approval requested", - tone: "approval", - payload: { - requestId: "req-dynamic-tool", - requestType: "dynamic_tool_call", - detail: "Search the web", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([ - { - requestId: "req-dynamic-tool", - requestKind: "command", - createdAt: "2026-02-23T00:00:01.000Z", - detail: "Search the web", - }, - ]); - }); - - it("clears stale pending approvals when provider reports unknown pending request", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-stale", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-stale-1", - requestType: "unknown", - }, - }), - makeActivity({ - id: "approval-failed-stale", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "provider.approval.respond.failed", - summary: "Provider approval response failed", - tone: "error", - payload: { - requestId: "req-stale-1", - detail: "Unknown pending permission request: req-stale-1", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([]); - }); - - it("clears stale pending approvals when the backend marks them stale after restart", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "approval-open-stale-restart", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "approval.requested", - summary: "Command approval requested", - tone: "approval", - payload: { - requestId: "req-stale-restart-1", - requestKind: "command", - }, - }), - makeActivity({ - id: "approval-failed-stale-restart", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "provider.approval.respond.failed", - summary: "Provider approval response failed", - tone: "error", - payload: { - requestId: "req-stale-restart-1", - detail: - "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", - }, - }), - ]; - - expect(derivePendingApprovals(activities)).toEqual([]); - }); -}); - -describe("derivePendingUserInputs", () => { - it("keeps free-text questions without suggested answers", () => { - const question = { - id: "0", - header: "Question", - question: "What should it be named?", - options: [], - allowCustomAnswer: true, - multiSelect: false, - }; - const activities = [ - makeActivity({ - id: "async-question", - kind: "user-input.requested", - summary: "User input requested", - payload: { requestId: "async-1", responseMode: "message", questions: [question] }, - }), - ]; - expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]); - }); - - it("preserves native choice values and the custom-answer restriction", () => { - const question = { - id: "interaction-result", - header: "Result", - question: "Which result should be used?", - options: [ - { value: " first\t", label: "Result", description: "First result" }, - { value: "second", label: "Result", description: "Second result" }, - ], - allowCustomAnswer: false, - multiSelect: false, - }; - const activities = [ - makeActivity({ - id: "native-user-input", - kind: "user-input.requested", - summary: "User input requested", - payload: { requestId: "req-native-choice", questions: [question] }, - }), - ]; - - expect(derivePendingUserInputs(activities)[0]?.questions).toEqual([question]); - }); - - it("tracks open structured prompts and removes resolved ones", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "user-input-open", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "user-input.requested", - summary: "User input requested", - tone: "info", - payload: { - requestId: "req-user-input-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - multiSelect: true, - }, - ], - }, - }), - makeActivity({ - id: "user-input-resolved", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "user-input.resolved", - summary: "User input submitted", - tone: "info", - payload: { - requestId: "req-user-input-2", - answers: { - sandbox_mode: "workspace-write", - }, - }, - }), - makeActivity({ - id: "user-input-open-2", - createdAt: "2026-02-23T00:00:01.500Z", - kind: "user-input.requested", - summary: "User input requested", - tone: "info", - payload: { - requestId: "req-user-input-2", - questions: [ - { - id: "approval", - header: "Approval", - question: "Continue?", - options: [ - { - label: "yes", - description: "Continue execution", - }, - ], - multiSelect: false, - }, - ], - }, - }), - ]; - - expect(derivePendingUserInputs(activities)).toEqual([ - { - requestId: "req-user-input-1", - createdAt: "2026-02-23T00:00:01.000Z", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - multiSelect: true, - }, - ], - }, - ]); - }); - - it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { - const activities: OrchestrationThreadActivity[] = [ - makeActivity({ - id: "user-input-open-stale", - createdAt: "2026-02-23T00:00:01.000Z", - kind: "user-input.requested", - summary: "User input requested", - tone: "info", - payload: { - requestId: "req-user-input-stale-1", - questions: [ - { - id: "sandbox_mode", - header: "Sandbox", - question: "Which mode should be used?", - options: [ - { - label: "workspace-write", - description: "Allow workspace writes only", - }, - ], - multiSelect: false, - }, - ], - }, - }), - makeActivity({ - id: "user-input-failed-stale", - createdAt: "2026-02-23T00:00:02.000Z", - kind: "provider.user-input.respond.failed", - summary: "Provider user input response failed", - tone: "error", - payload: { - requestId: "req-user-input-stale-1", - detail: - "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1", - }, - }), - ]; - - expect(derivePendingUserInputs(activities)).toEqual([]); - }); -}); - describe("deriveActivePlanState", () => { it("returns the latest plan update for the active turn", () => { const activities: OrchestrationThreadActivity[] = [ diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 4e56d742bc65..bbef3a4945a6 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -1,6 +1,9 @@ +import { + requestKindFromRequestType, + type PendingApproval, +} from "@t3tools/client-runtime/pending-requests"; import * as Option from "effect/Option"; import * as Arr from "effect/Array"; -import * as Schema from "effect/Schema"; import { shallow } from "zustand/vanilla/shallow"; import { isBackgroundTaskActivity } from "@t3tools/client-runtime/state/subagentRuntime"; import { @@ -15,16 +18,12 @@ import { } from "@t3tools/client-runtime/work-log/presentation"; import { extractToolActivityPresentation } from "@t3tools/client-runtime/work-log/tool-presentation"; import { - ApprovalRequestId, isToolLifecycleItemType, type AssetResource, type OrchestrationLatestTurn, type OrchestrationThreadActivity, type OrchestrationProposedPlanId, - ProviderApprovalOption, - ProviderRequestKind, type ToolLifecycleItemType, - type UserInputQuestion, type ThreadId, type TurnId, } from "@t3tools/contracts"; @@ -40,6 +39,8 @@ import { type TurnDiffSummary, } from "./types"; +export type { PendingApproval, PendingUserInput } from "@t3tools/client-runtime/pending-requests"; + export { formatDuration } from "@t3tools/shared/orchestrationTiming"; export { @@ -106,24 +107,6 @@ const derivedWorkLogEntryByActivity = new WeakMap< DerivedWorkLogEntry >(); -export interface PendingApproval { - requestId: ApprovalRequestId; - requestKind: ProviderRequestKind; - createdAt: string; - detail?: string; - appName?: string; - options?: ReadonlyArray; -} - -const isProviderRequestKind = Schema.is(ProviderRequestKind); -const isProviderApprovalOption = Schema.is(ProviderApprovalOption); - -export interface PendingUserInput { - requestId: ApprovalRequestId; - createdAt: string; - questions: ReadonlyArray; -} - export interface ActivePlanState { createdAt: string; turnId: TurnId | null; @@ -236,208 +219,6 @@ export function deriveActiveWorkStartedAt( return sendStartedAt; } -function requestKindFromRequestType(requestType: unknown): PendingApproval["requestKind"] | null { - switch (requestType) { - case "command_execution_approval": - case "exec_command_approval": - case "dynamic_tool_call": - return "command"; - case "file_read_approval": - return "file-read"; - case "file_change_approval": - case "apply_patch_approval": - return "file-change"; - case "mcp_elicitation_approval": - return "mcp-elicitation"; - default: - return null; - } -} - -function isStalePendingRequestFailureDetail(detail: string | undefined): boolean { - const normalized = detail?.toLowerCase(); - if (!normalized) { - return false; - } - return ( - normalized.includes("stale pending approval request") || - normalized.includes("stale pending user-input request") || - normalized.includes("unknown pending approval request") || - normalized.includes("unknown pending permission request") || - normalized.includes("unknown pending user-input request") || - normalized.includes("unknown pending user input request") || - normalized.includes("unknown pending codex user input request") - ); -} - -export function derivePendingApprovals( - activities: ReadonlyArray, -): PendingApproval[] { - const openByRequestId = new Map(); - const ordered = [...activities].toSorted(compareActivitiesByOrder); - - for (const activity of ordered) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = - payload && typeof payload.requestId === "string" - ? ApprovalRequestId.make(payload.requestId) - : null; - const requestKind = - payload && isProviderRequestKind(payload.requestKind) - ? payload.requestKind - : payload - ? requestKindFromRequestType(payload.requestType) - : null; - const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; - const appName = payload && typeof payload.appName === "string" ? payload.appName : undefined; - const options = Array.isArray(payload?.options) - ? payload.options.filter(isProviderApprovalOption) - : undefined; - - if ( - activity.kind === "approval.requested" && - requestId && - payload?.requestType !== "tool_user_input" && - payload?.requestType !== "auth_tokens_refresh" - ) { - openByRequestId.set(requestId, { - requestId, - // Older OpenCode requests can have no recognized approval kind. - requestKind: requestKind ?? "command", - createdAt: activity.createdAt, - ...(detail ? { detail } : {}), - ...(appName ? { appName } : {}), - ...(options && options.length > 0 ? { options } : {}), - }); - continue; - } - - if (activity.kind === "approval.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.approval.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - continue; - } - } - - return [...openByRequestId.values()].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); -} - -function parseUserInputQuestions( - payload: Record | null, -): ReadonlyArray | null { - const questions = payload?.questions; - if (!Array.isArray(questions)) { - return null; - } - const parsed = questions - .map((entry) => { - if (!entry || typeof entry !== "object") return null; - const question = entry as Record; - if ( - typeof question.id !== "string" || - typeof question.header !== "string" || - typeof question.question !== "string" || - !Array.isArray(question.options) - ) { - return null; - } - const options = question.options - .map((option) => { - if (!option || typeof option !== "object") return null; - const optionRecord = option as Record; - if ( - typeof optionRecord.label !== "string" || - typeof optionRecord.description !== "string" - ) { - return null; - } - return { - label: optionRecord.label, - description: optionRecord.description, - ...(typeof optionRecord.value === "string" ? { value: optionRecord.value } : {}), - }; - }) - .filter((option): option is UserInputQuestion["options"][number] => option !== null); - if (options.length === 0 && question.allowCustomAnswer === false) { - return null; - } - return { - id: question.id, - header: question.header, - question: question.question, - options, - multiSelect: question.multiSelect === true, - ...(typeof question.allowCustomAnswer === "boolean" - ? { allowCustomAnswer: question.allowCustomAnswer } - : {}), - }; - }) - .filter((question): question is UserInputQuestion => question !== null); - return parsed.length > 0 ? parsed : null; -} - -export function derivePendingUserInputs( - activities: ReadonlyArray, -): PendingUserInput[] { - const openByRequestId = new Map(); - const ordered = [...activities].toSorted(compareActivitiesByOrder); - - for (const activity of ordered) { - const payload = - activity.payload && typeof activity.payload === "object" - ? (activity.payload as Record) - : null; - const requestId = - payload && typeof payload.requestId === "string" - ? ApprovalRequestId.make(payload.requestId) - : null; - const detail = payload && typeof payload.detail === "string" ? payload.detail : undefined; - - if (activity.kind === "user-input.requested" && requestId) { - const questions = parseUserInputQuestions(payload); - if (!questions) { - continue; - } - openByRequestId.set(requestId, { - requestId, - createdAt: activity.createdAt, - questions, - }); - continue; - } - - if (activity.kind === "user-input.resolved" && requestId) { - openByRequestId.delete(requestId); - continue; - } - - if ( - activity.kind === "provider.user-input.respond.failed" && - requestId && - isStalePendingRequestFailureDetail(detail) - ) { - openByRequestId.delete(requestId); - } - } - - return [...openByRequestId.values()].toSorted((left, right) => - left.createdAt.localeCompare(right.createdAt), - ); -} - function planStateFromActivity(activity: OrchestrationThreadActivity): ActivePlanState | null { const payload = activity.payload && typeof activity.payload === "object" diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 775a4a898f8f..fdab38c9cc62 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -11,6 +11,10 @@ "types": "./src/projectFaviconCache.ts", "default": "./src/projectFaviconCache.ts" }, + "./pending-requests": { + "types": "./src/pendingRequests.ts", + "default": "./src/pendingRequests.ts" + }, "./connection": { "types": "./src/connection/index.ts", "default": "./src/connection/index.ts" diff --git a/packages/client-runtime/src/pendingRequests.test.ts b/packages/client-runtime/src/pendingRequests.test.ts new file mode 100644 index 000000000000..6ef4579044c2 --- /dev/null +++ b/packages/client-runtime/src/pendingRequests.test.ts @@ -0,0 +1,497 @@ +import { EventId, TurnId, type OrchestrationThreadActivity } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { derivePendingRequests } from "./pendingRequests.ts"; + +let nextActivityId = 0; + +function makeActivity(overrides: { + id?: string; + createdAt?: string; + kind?: string; + summary?: string; + tone?: OrchestrationThreadActivity["tone"]; + payload?: Record; + turnId?: string; + sequence?: number; +}): OrchestrationThreadActivity { + return { + id: EventId.make(overrides.id ?? `activity-${nextActivityId++}`), + createdAt: overrides.createdAt ?? "2026-02-23T00:00:00.000Z", + kind: overrides.kind ?? "tool.started", + summary: overrides.summary ?? "Tool call", + tone: overrides.tone ?? "tool", + payload: overrides.payload ?? {}, + turnId: overrides.turnId ? TurnId.make(overrides.turnId) : null, + ...(overrides.sequence !== undefined ? { sequence: overrides.sequence } : {}), + }; +} + +describe("pending approvals", () => { + it.each([{}, { requestType: "unknown" }])( + "exposes legacy OpenCode approvals without a known request kind: %j", + (legacyPayload) => { + const requested = makeActivity({ + kind: "approval.requested", + payload: { requestId: "per-legacy", detail: "*", ...legacyPayload }, + }); + + expect(derivePendingRequests([requested]).approvals).toEqual([ + { + requestId: "per-legacy", + requestKind: "command", + createdAt: requested.createdAt, + detail: "*", + }, + ]); + }, + ); + + it.each(["tool_user_input", "auth_tokens_refresh"])( + "does not turn %s into an approval", + (requestType) => { + const activity = makeActivity({ + kind: "approval.requested", + payload: { requestId: "not-an-approval", requestType }, + }); + + expect(derivePendingRequests([activity]).approvals).toEqual([]); + }, + ); + + it("tracks open approvals and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-1", + requestKind: "command", + detail: "bun run lint", + }, + }), + makeActivity({ + id: "approval-close", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "approval.resolved", + summary: "Approval resolved", + tone: "info", + payload: { requestId: "req-2" }, + }), + makeActivity({ + id: "approval-closed-request", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "approval.requested", + summary: "File-change approval requested", + tone: "approval", + payload: { requestId: "req-2", requestType: "unknown" }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-1", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "bun run lint", + }, + ]); + }); + + it("maps canonical requestType payloads into pending approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-request-type", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-request-type", + requestType: "command_execution_approval", + detail: "pwd", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-request-type", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "pwd", + }, + ]); + }); + + it("keeps app access approvals and persistence choices from remote activities", () => { + const options = [ + { decision: "decline", label: "Decline" }, + { decision: "acceptAlways", label: "Always allow Safari" }, + { decision: "accept", label: "Approve" }, + ]; + const activities = [ + makeActivity({ + kind: "approval.requested", + summary: "App access approval requested", + tone: "approval", + payload: { + requestId: "req-safari", + requestType: "mcp_elicitation_approval", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-safari", + requestKind: "mcp-elicitation", + createdAt: "2026-02-23T00:00:00.000Z", + detail: "Allow ChatGPT to use Safari?", + appName: "Safari", + options, + }, + ]); + }); + + it("derives dynamic tool requests as actionable generic approvals", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-dynamic-tool", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Approval requested", + tone: "approval", + payload: { + requestId: "req-dynamic-tool", + requestType: "dynamic_tool_call", + detail: "Search the web", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([ + { + requestId: "req-dynamic-tool", + requestKind: "command", + createdAt: "2026-02-23T00:00:01.000Z", + detail: "Search the web", + }, + ]); + }); + + it("clears stale pending approvals when provider reports unknown pending request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-stale-1", + requestType: "unknown", + }, + }), + makeActivity({ + id: "approval-failed-stale", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + tone: "error", + payload: { + requestId: "req-stale-1", + detail: "Unknown pending permission request: req-stale-1", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([]); + }); + + it("clears stale pending approvals when the backend marks them stale after restart", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "approval-open-stale-restart", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "approval.requested", + summary: "Command approval requested", + tone: "approval", + payload: { + requestId: "req-stale-restart-1", + requestKind: "command", + }, + }), + makeActivity({ + id: "approval-failed-stale-restart", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.approval.respond.failed", + summary: "Provider approval response failed", + tone: "error", + payload: { + requestId: "req-stale-restart-1", + detail: + "Stale pending approval request: req-stale-restart-1. Provider callback state does not survive app restarts or recovered sessions. Restart the turn to continue.", + }, + }), + ]; + + expect(derivePendingRequests(activities).approvals).toEqual([]); + }); +}); + +describe("pending questions", () => { + it("preserves native answer keys while ignoring malformed options", () => { + const question = { + id: " Which path?\n", + header: " Path ", + question: " Which path?\n", + options: [{ label: " Keep spaces ", description: "", value: " native\t" }], + multiSelect: false, + }; + const requested = makeActivity({ + kind: "user-input.requested", + payload: { + requestId: "native-question", + questions: [null, { ...question, options: [...question.options, { label: 42 }] }], + }, + }); + + expect(derivePendingRequests([requested]).userInputs[0]?.questions).toEqual([question]); + }); + + it("keeps free-text questions without suggested answers", () => { + const question = { + id: "0", + header: "Question", + question: "What should it be named?", + options: [], + allowCustomAnswer: true, + multiSelect: false, + }; + const activities = [ + makeActivity({ + id: "async-question", + kind: "user-input.requested", + summary: "User input requested", + payload: { requestId: "async-1", responseMode: "message", questions: [question] }, + }), + ]; + expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]); + }); + + it("preserves native choice values and the custom-answer restriction", () => { + const question = { + id: "interaction-result", + header: "Result", + question: "Which result should be used?", + options: [ + { value: " first\t", label: "Result", description: "First result" }, + { value: "second", label: "Result", description: "Second result" }, + ], + allowCustomAnswer: false, + multiSelect: false, + }; + const activities = [ + makeActivity({ + id: "native-user-input", + kind: "user-input.requested", + summary: "User input requested", + payload: { requestId: "req-native-choice", questions: [question] }, + }), + ]; + + expect(derivePendingRequests(activities).userInputs[0]?.questions).toEqual([question]); + }); + + it("tracks open structured prompts and removes resolved ones", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + multiSelect: true, + }, + ], + }, + }), + makeActivity({ + id: "user-input-resolved", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "user-input.resolved", + summary: "User input submitted", + tone: "info", + payload: { + requestId: "req-user-input-2", + answers: { + sandbox_mode: "workspace-write", + }, + }, + }), + makeActivity({ + id: "user-input-open-2", + createdAt: "2026-02-23T00:00:01.500Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-2", + questions: [ + { + id: "approval", + header: "Approval", + question: "Continue?", + options: [ + { + label: "yes", + description: "Continue execution", + }, + ], + multiSelect: false, + }, + ], + }, + }), + ]; + + expect(derivePendingRequests(activities).userInputs).toEqual([ + { + requestId: "req-user-input-1", + createdAt: "2026-02-23T00:00:01.000Z", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + multiSelect: true, + }, + ], + }, + ]); + }); + + it("clears stale pending user-input prompts when the provider reports an orphaned request", () => { + const activities: OrchestrationThreadActivity[] = [ + makeActivity({ + id: "user-input-open-stale", + createdAt: "2026-02-23T00:00:01.000Z", + kind: "user-input.requested", + summary: "User input requested", + tone: "info", + payload: { + requestId: "req-user-input-stale-1", + questions: [ + { + id: "sandbox_mode", + header: "Sandbox", + question: "Which mode should be used?", + options: [ + { + label: "workspace-write", + description: "Allow workspace writes only", + }, + ], + multiSelect: false, + }, + ], + }, + }), + makeActivity({ + id: "user-input-failed-stale", + createdAt: "2026-02-23T00:00:02.000Z", + kind: "provider.user-input.respond.failed", + summary: "Provider user input response failed", + tone: "error", + payload: { + requestId: "req-user-input-stale-1", + detail: + "Provider adapter request failed (codex) for item/tool/requestUserInput: Unknown pending Codex user input request: req-user-input-stale-1", + }, + }), + ]; + + expect(derivePendingRequests(activities).userInputs).toEqual([]); + }); +}); + +describe.each(["approval", "user-input"])("%s request completion", (requestKind) => { + const requested = makeActivity({ + id: `${requestKind}-requested`, + kind: `${requestKind}.requested`, + sequence: 42, + payload: { + requestId: "request-1", + requestKind: "command", + questions: [{ id: "answer", header: "Answer", question: "Continue?", options: [] }], + }, + }); + + it.each([`${requestKind}.resolved`, `provider.${requestKind}.respond.failed`])( + "keeps %s final across reordered and repeated activities", + (kind) => { + const closed = makeActivity({ + id: `${requestKind}-closed`, + kind, + createdAt: "2026-02-23T00:00:01.000Z", + payload: { + requestId: "request-1", + detail: `Unknown pending ${requestKind} request: request-1`, + }, + }); + const replayedRequest = { ...requested, id: EventId.make("replayed-request"), sequence: 43 }; + + for (const activities of [ + [requested, closed, replayedRequest], + [closed, requested, replayedRequest], + ]) { + expect(derivePendingRequests(activities)).toEqual({ approvals: [], userInputs: [] }); + } + }, + ); + + it("keeps a failed reply retryable unless the text names a stale request", () => { + const failed = makeActivity({ + kind: `provider.${requestKind}.respond.failed`, + payload: { requestId: "request-1", detail: "Provider adapter request failed: timeout" }, + }); + const pending = derivePendingRequests([requested, failed]); + expect( + [...pending.approvals, ...pending.userInputs].map((request) => request.requestId), + ).toEqual(["request-1"]); + + const retried = makeActivity({ + kind: `${requestKind}.resolved`, + payload: { requestId: "request-1" }, + }); + expect(derivePendingRequests([requested, failed, retried, failed])).toEqual({ + approvals: [], + userInputs: [], + }); + }); +}); diff --git a/packages/client-runtime/src/pendingRequests.ts b/packages/client-runtime/src/pendingRequests.ts new file mode 100644 index 000000000000..a94c49514e5d --- /dev/null +++ b/packages/client-runtime/src/pendingRequests.ts @@ -0,0 +1,189 @@ +import { + ApprovalRequestId, + type OrchestrationThreadActivity, + ProviderApprovalOption, + ProviderRequestKind, + UserInputQuestion, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Schema from "effect/Schema"; + +export interface PendingApproval { + readonly requestId: ApprovalRequestId; + readonly requestKind: ProviderRequestKind; + readonly createdAt: string; + readonly detail?: string; + readonly appName?: string; + readonly options?: ReadonlyArray; +} + +export interface PendingUserInput { + readonly requestId: ApprovalRequestId; + readonly createdAt: string; + readonly questions: ReadonlyArray; +} + +const isRequestId = Schema.is(ApprovalRequestId); +const isProviderRequestKind = Schema.is(ProviderRequestKind); +const isProviderApprovalOption = Schema.is(ProviderApprovalOption); +const QuestionOption = Schema.Struct({ + ...UserInputQuestion.fields.options.value.fields, + label: Schema.String, +}); +const isQuestionOption = Schema.is(QuestionOption); +// Native question IDs and option labels can be answer keys. Do not trim them. +const decodeQuestion = Schema.decodeUnknownOption( + Schema.Struct({ + ...UserInputQuestion.fields, + id: Schema.String, + header: Schema.String, + question: Schema.String, + options: Schema.Array(QuestionOption), + }), +); + +/** Older activities use native request types instead of a request kind. */ +export function requestKindFromRequestType(requestType: unknown): ProviderRequestKind | null { + switch (requestType) { + case "command_execution_approval": + case "exec_command_approval": + case "dynamic_tool_call": + return "command"; + case "file_read_approval": + return "file-read"; + case "file_change_approval": + case "apply_patch_approval": + return "file-change"; + case "mcp_elicitation_approval": + return "mcp-elicitation"; + default: + return null; + } +} + +function parseQuestions(value: unknown): UserInputQuestion[] { + if (!Array.isArray(value)) return []; + return value.flatMap((question) => { + if (!Predicate.isObject(question) || !Array.isArray(question.options)) return []; + const options = question.options.filter(isQuestionOption); + if (options.length === 0 && question.allowCustomAnswer === false) return []; + const parsed = decodeQuestion({ + id: question.id, + header: question.header, + question: question.question, + options, + multiSelect: question.multiSelect === true, + ...(typeof question.allowCustomAnswer === "boolean" + ? { allowCustomAnswer: question.allowCustomAnswer } + : {}), + }); + return Option.isSome(parsed) ? [parsed.value] : []; + }); +} + +const requestActivityKinds = new Set([ + "approval.requested", + "approval.resolved", + "provider.approval.respond.failed", + "user-input.requested", + "user-input.resolved", + "provider.user-input.respond.failed", +]); + +// The server reports a stale or unknown request through the failure text. +// A failed reply with any other text stays open so the user can retry. +const staleRequestFailureDetails = { + "provider.approval.respond.failed": [ + "stale pending approval request", + "unknown pending approval request", + "unknown pending permission request", + "unknown pending codex approval request", + ], + "provider.user-input.respond.failed": [ + "stale pending user-input request", + "unknown pending user-input request", + "unknown pending user input request", + "unknown pending codex user input request", + ], +} as const; + +function isStaleRequestFailure( + kind: keyof typeof staleRequestFailureDetails, + payload: Record, +): boolean { + const detail = typeof payload.detail === "string" ? payload.detail.toLowerCase() : ""; + return staleRequestFailureDetails[kind].some((fragment) => detail.includes(fragment)); +} + +/** Reduces request state once for web, desktop, and mobile. Layout stays with each client. */ +export function derivePendingRequests(activities: ReadonlyArray) { + const approvals = new Map(); + const userInputs = new Map(); + const closedApprovals = new Set(); + const closedUserInputs = new Set(); + + // Request IDs are unique. A terminal event stays final even when provider + // sequences and server-generated activities arrive in a different order. + for (const activity of activities) { + if (!requestActivityKinds.has(activity.kind)) continue; + const payload = Predicate.isObject(activity.payload) ? activity.payload : undefined; + if (!payload || !isRequestId(payload.requestId)) continue; + const requestId = payload.requestId; + + if (activity.kind === "approval.requested") { + if ( + closedApprovals.has(requestId) || + payload.requestType === "tool_user_input" || + payload.requestType === "auth_tokens_refresh" + ) { + continue; + } + const requestKind = isProviderRequestKind(payload.requestKind) + ? payload.requestKind + : requestKindFromRequestType(payload.requestType); + const options = Array.isArray(payload.options) + ? payload.options.filter(isProviderApprovalOption) + : []; + approvals.set(requestId, { + requestId, + // Older OpenCode approvals do not always include a recognized kind. + requestKind: requestKind ?? "command", + createdAt: activity.createdAt, + ...(typeof payload.detail === "string" && payload.detail ? { detail: payload.detail } : {}), + ...(typeof payload.appName === "string" && payload.appName + ? { appName: payload.appName } + : {}), + ...(options.length > 0 ? { options } : {}), + }); + } else if (activity.kind === "user-input.requested") { + if (closedUserInputs.has(requestId)) continue; + const questions = parseQuestions(payload.questions); + if (questions.length === 0) continue; + userInputs.set(requestId, { requestId, createdAt: activity.createdAt, questions }); + } else if ( + activity.kind === "approval.resolved" || + (activity.kind === "provider.approval.respond.failed" && + isStaleRequestFailure(activity.kind, payload)) + ) { + closedApprovals.add(requestId); + approvals.delete(requestId); + } else if ( + activity.kind === "user-input.resolved" || + (activity.kind === "provider.user-input.respond.failed" && + isStaleRequestFailure(activity.kind, payload)) + ) { + closedUserInputs.add(requestId); + userInputs.delete(requestId); + } + } + + const byCreatedAt = ( + left: { readonly createdAt: string }, + right: { readonly createdAt: string }, + ) => left.createdAt.localeCompare(right.createdAt); + return { + approvals: [...approvals.values()].sort(byCreatedAt), + userInputs: [...userInputs.values()].sort(byCreatedAt), + }; +} From e4e9fa9a0b1a77e393ab6cffd75ec22dbb258bdc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:56:42 -0700 Subject: [PATCH 219/320] perf(server): finish runtime messages without full thread reads (#10120) Co-authored-by: Ross Cawston --- .../Layers/ProviderRuntimeIngestion.test.ts | 267 +++++++++++---- .../Layers/ProviderRuntimeIngestion.ts | 313 +++++++----------- .../Layers/ProjectionRepositories.test.ts | 48 ++- .../Layers/ProjectionThreadActivities.test.ts | 100 ++++++ .../Layers/ProjectionThreadActivities.ts | 81 ++++- .../Layers/ProjectionThreadMessages.test.ts | 47 ++- .../Layers/ProjectionThreadMessages.ts | 31 ++ .../Layers/ProjectionThreadProposedPlans.ts | 27 ++ .../Services/ProjectionThreadActivities.ts | 19 +- .../Services/ProjectionThreadMessages.ts | 15 + .../Services/ProjectionThreadProposedPlans.ts | 11 + 11 files changed, 690 insertions(+), 269 deletions(-) create mode 100644 apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index dd6a0b179a8f..1094ab48b7ac 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -286,9 +286,20 @@ describe("ProviderRuntimeIngestion", () => { Layer.provide(RepositoryIdentityResolver.layer), Layer.provide(SqlitePersistenceMemory), ); + const ingestionProjectionSnapshotLayer = Layer.effect( + ProjectionSnapshotQuery, + Effect.gen(function* () { + const query = yield* ProjectionSnapshotQuery; + return ProjectionSnapshotQuery.of({ + ...query, + getThreadDetailById: () => + Effect.die("provider runtime ingestion must not hydrate thread detail"), + }); + }), + ).pipe(Layer.provide(projectionSnapshotLayer)); const layer = ProviderRuntimeIngestionLive.pipe( Layer.provideMerge(orchestrationLayer), - Layer.provideMerge(projectionSnapshotLayer), + Layer.provideMerge(ingestionProjectionSnapshotLayer), // Single shared liveness instance across ingestion (writer), the // engine, and the snapshot query (reader). Layer.provideMerge(ThreadBackgroundLiveness.layer), @@ -483,6 +494,89 @@ describe("ProviderRuntimeIngestion", () => { ]); }); + it.each(["turn.completed", "turn.aborted"] as const)( + "finalizes old buffered text on late %s without stopping the newer turn", + async (terminalType) => { + const harness = await createHarness({ + serverSettings: { enableLegacyTokenStreaming: false }, + }); + const threadId = asThreadId("thread-1"); + const oldTurnId = asTurnId("old-buffered-turn"); + const newTurnId = asTurnId("new-active-turn"); + const base = { + provider: ProviderDriverKind.make("opencode"), + threadId, + createdAt: "2026-01-01T00:00:01.000Z", + }; + await harness.emitAndDrain([ + { + ...base, + type: "turn.started", + eventId: asEventId("old-buffered-started"), + turnId: oldTurnId, + }, + { + ...base, + type: "content.delta", + eventId: asEventId("old-buffered-delta"), + turnId: oldTurnId, + itemId: asItemId("old-buffered-message"), + payload: { streamKind: "assistant_text", delta: "Keep the old answer." }, + }, + ]); + await harness.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("start-new-while-old-finishes"), + threadId, + message: { + messageId: asMessageId("new-turn-prompt"), + role: "user", + text: "Continue", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: base.createdAt, + }); + harness.setProviderSession({ + provider: base.provider, + status: "running", + runtimeMode: "approval-required", + threadId, + createdAt: base.createdAt, + updatedAt: base.createdAt, + activeTurnId: newTurnId, + }); + await harness.emitAndDrain([ + { + ...base, + type: "turn.started", + eventId: asEventId("new-active-started"), + turnId: newTurnId, + }, + { + ...base, + type: terminalType, + eventId: asEventId("old-buffered-terminal"), + turnId: oldTurnId, + payload: + terminalType === "turn.completed" + ? { state: "completed" } + : { reason: "Interrupted by user." }, + }, + ]); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.session).toMatchObject({ activeTurnId: newTurnId, status: "running" }); + expect(thread?.messages).toContainEqual( + expect.objectContaining({ + turnId: oldTurnId, + text: "Keep the old answer.", + streaming: false, + }), + ); + }, + ); + it.each([ { source: "the previous turn", turnId: asTurnId("opencode-stopped-turn") }, { source: "an unspecified turn", turnId: undefined }, @@ -1690,6 +1784,31 @@ describe("ProviderRuntimeIngestion", () => { ).toMatchObject({ implementationThreadId: "thread-implement", }); + const implementedPlan = sourceThreadAfterStart.proposedPlans.find( + (entry) => entry.id === sourcePlan.id, + ); + await harness.emitAndDrain([ + { + type: "turn.proposed.completed", + eventId: asEventId("evt-plan-source-late-completion"), + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:01:00.000Z", + threadId: sourceThreadId, + turnId: sourceTurnId, + payload: { planMarkdown: "# Source plan with late details" }, + }, + ]); + const sourceAfterLateCompletion = (await harness.readModel()).threads.find( + (entry) => entry.id === sourceThreadId, + ); + expect( + sourceAfterLateCompletion?.proposedPlans.find((entry) => entry.id === sourcePlan.id), + ).toMatchObject({ + planMarkdown: "# Source plan with late details", + createdAt: sourcePlan.createdAt, + implementedAt: implementedPlan?.implementedAt, + implementationThreadId: targetThreadId, + }); }); it("does not mark the source proposed plan implemented for a rejected turn.started event", async () => { @@ -2120,7 +2239,7 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-1"), provider: ProviderDriverKind.make("codex"), - createdAt: now, + createdAt: "", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), payload: { @@ -2131,7 +2250,7 @@ describe("ProviderRuntimeIngestion", () => { type: "turn.proposed.delta", eventId: asEventId("evt-plan-delta-2"), provider: ProviderDriverKind.make("codex"), - createdAt: now, + createdAt: "", threadId: asThreadId("thread-1"), turnId: asTurnId("turn-plan-buffer"), payload: { @@ -2161,6 +2280,42 @@ describe("ProviderRuntimeIngestion", () => { entry.id === "plan:thread-1:turn:turn-plan-buffer", ); expect(proposedPlan?.planMarkdown).toBe("## Buffered plan\n\n- first\n- second"); + expect(proposedPlan?.createdAt).toBe(now); + }); + + it("releases a blank completed plan before a late replacement", async () => { + const harness = await createHarness(); + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("blank-plan-turn"); + const base = { provider: ProviderDriverKind.make("codex"), threadId, turnId }; + const replacementTime = "2026-01-01T00:00:02.000Z"; + await harness.emitAndDrain([ + { + ...base, + type: "turn.proposed.delta", + eventId: asEventId("blank-plan-delta"), + createdAt: "2026-01-01T00:00:00.000Z", + payload: { delta: " \n " }, + }, + { + ...base, + type: "turn.completed", + eventId: asEventId("blank-plan-completed"), + createdAt: "2026-01-01T00:00:01.000Z", + payload: { state: "completed" }, + }, + { + ...base, + type: "turn.proposed.completed", + eventId: asEventId("late-plan-completed"), + createdAt: replacementTime, + payload: { planMarkdown: "# Replacement plan" }, + }, + ]); + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + expect(thread?.proposedPlans).toEqual([ + expect.objectContaining({ planMarkdown: "# Replacement plan", createdAt: replacementTime }), + ]); }); it("buffers assistant deltas with one lifecycle query per event until completion", async () => { @@ -4037,71 +4192,65 @@ describe("ProviderRuntimeIngestion", () => { expect(completedPayload?.title).toBe("wait for codex review to finish"); }); - it("titles task completion from persisted activities after the description cache is swept", async () => { + it("recovers a task title past untitled progress after the cache is swept", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; + const threadId = asThreadId("thread-1"); + const turnId = asTurnId("turn-swept-task"); + const provider = ProviderDriverKind.make("claudeAgent"); - harness.emit({ - type: "task.progress", - eventId: asEventId("evt-swept-task-progress"), - provider: ProviderDriverKind.make("claudeAgent"), - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-swept-task"), - payload: { - taskId: "swept-task-1", - description: "Watch round-3 CI and bots", + await harness.emitAndDrain([ + { + type: "task.started", + eventId: asEventId("evt-swept-task-started"), + provider, + createdAt: now, + threadId, + turnId, + payload: { taskId: "swept-task-1", description: "Watch round-3 CI and bots" }, + }, + ]); + // Older saved progress rows can have no title even when the start has one. + await harness.dispatch({ + type: "thread.activity.append", + commandId: CommandId.make("cmd-swept-task-progress"), + threadId, + activity: { + id: asEventId("evt-swept-task-progress"), + kind: "task.progress", + tone: "info", summary: "Polling CI checks.", + payload: { taskId: "swept-task-1" }, + turnId, + createdAt: "2026-01-01T00:00:01.000Z", }, + createdAt: "2026-01-01T00:00:01.000Z", }); - - await waitForThread(harness.readModel, (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => - activity.id === "task-progress:thread-1:swept-task-1", - ), - ); - - // session.exited sweeps the in-memory description cache; the completion - // that follows must recover the name from persisted activities. - harness.emit({ - type: "session.exited", - eventId: asEventId("evt-swept-task-session-exited"), - provider: ProviderDriverKind.make("claudeAgent"), - createdAt: now, - threadId: asThreadId("thread-1"), - payload: {}, - }); - - harness.emit({ - type: "task.completed", - eventId: asEventId("evt-swept-task-completed"), - provider: ProviderDriverKind.make("claudeAgent"), - createdAt: now, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-swept-task"), - payload: { - taskId: "swept-task-1", - status: "completed", - summary: "CI is green.", + await harness.emitAndDrain([ + { + type: "session.exited", + eventId: asEventId("evt-swept-task-session-exited"), + provider, + createdAt: "2026-01-01T00:00:02.000Z", + threadId, + payload: {}, }, - }); - - const thread = await waitForThread(harness.readModel, (entry) => - entry.activities.some( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", - ), - ); + { + type: "task.completed", + eventId: asEventId("evt-swept-task-completed"), + provider, + createdAt: "2026-01-01T00:00:03.000Z", + threadId, + turnId, + payload: { taskId: "swept-task-1", status: "completed", summary: "CI is green." }, + }, + ]); - const completed = thread.activities.find( - (activity: ProviderRuntimeTestActivity) => activity.id === "evt-swept-task-completed", + const thread = (await harness.readModel()).threads.find((entry) => entry.id === threadId); + const completed = thread?.activities.find( + (activity) => activity.id === "evt-swept-task-completed", ); - const completedPayload = - completed?.payload && typeof completed.payload === "object" - ? (completed.payload as Record) - : undefined; - - expect(completedPayload?.title).toBe("Watch round-3 CI and bots"); + expect(completed?.payload).toMatchObject({ title: "Watch round-3 CI and bots" }); }); it("projects structured user input request and resolution as thread activities", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index cf59d00a248b..8d34fee4f981 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -4,8 +4,7 @@ import { CommandId, MessageId, type OrchestrationEvent, - type OrchestrationMessage, - type OrchestrationProposedPlanId, + OrchestrationProposedPlanId, CheckpointRef, classifyTaskAgentKind, EventId, @@ -14,8 +13,6 @@ import { type ThreadTokenUsageSnapshot, TurnId, type OrchestrationCheckpointSummary, - type OrchestrationProposedPlan, - type OrchestrationThread, type OrchestrationThreadActivity, type ProviderRuntimeEvent, RuntimeRequestId, @@ -39,6 +36,10 @@ import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/Projectio import { ProjectionThreadActivityRepository } from "../../persistence/Services/ProjectionThreadActivities.ts"; import { ProjectionThreadActivityRepositoryLive } from "../../persistence/Layers/ProjectionThreadActivities.ts"; import * as CheckpointStore from "../../checkpointing/CheckpointStore.ts"; +import { ProjectionThreadMessageRepository } from "../../persistence/Services/ProjectionThreadMessages.ts"; +import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ProjectionThreadMessages.ts"; +import { ProjectionThreadProposedPlanRepository } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; +import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; import { ThreadBackgroundLivenessService } from "../ThreadBackgroundLiveness.ts"; import { ThreadPlanProgressService } from "../ThreadPlanProgress.ts"; @@ -54,13 +55,12 @@ 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 // task.started/task.progress activities for the task are persisted with it. function findTaskTitleInActivities( - activities: ReadonlyArray | undefined, + activities: ReadonlyArray<{ readonly kind: string; readonly payload: unknown }> | undefined, taskId: string, ): string | undefined { if (!activities) { @@ -138,57 +138,6 @@ function sameId(left: string | null | undefined, right: string | null | undefine return left === right; } -function hasAssistantMessageForTurn( - messages: ReadonlyArray, - turnId: TurnId, - options?: { readonly streamingOnly?: boolean }, -): boolean { - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]; - if (!message) { - continue; - } - if (message.role !== "assistant" || message.turnId !== turnId) { - continue; - } - if (options?.streamingOnly === true && !message.streaming) { - continue; - } - return true; - } - return false; -} - -function findMessageById( - messages: ReadonlyArray, - messageId: MessageId, -): OrchestrationMessage | undefined { - for (let index = 0; index < messages.length; index += 1) { - const message = messages[index]; - if (message?.id === messageId) { - return message; - } - } - return undefined; -} - -function findProposedPlanById( - proposedPlans: ReadonlyArray< - Pick - >, - planId: string, -): - | Pick - | undefined { - for (let index = 0; index < proposedPlans.length; index += 1) { - const proposedPlan = proposedPlans[index]; - if (proposedPlan?.id === planId) { - return proposedPlan; - } - } - return undefined; -} - function hasCheckpointForTurn( checkpoints: ReadonlyArray, turnId: TurnId, @@ -264,16 +213,15 @@ function buildContextWindowActivityPayload( } function compactedTokenCountsFromActivities( - activities: ReadonlyArray | undefined, + activities: ReadonlyArray< + Pick + >, ): { readonly beforeTokens: number; readonly afterTokens: number } | undefined { - const lastCompactionIndex = activities?.findLastIndex( + const lastCompactionIndex = activities.findLastIndex( (activity) => activity.kind === "context-compaction", ); - const lastCompaction = - lastCompactionIndex !== undefined && lastCompactionIndex >= 0 - ? activities?.[lastCompactionIndex] - : undefined; - const activitiesSinceLastCompaction = activities?.slice((lastCompactionIndex ?? -1) + 1) ?? []; + const lastCompaction = activities[lastCompactionIndex]; + const activitiesSinceLastCompaction = activities.slice(lastCompactionIndex + 1); const usedTokens = activitiesSinceLastCompaction.flatMap((activity) => { if (activity.kind !== "context-window.updated") return []; if (lastCompaction !== undefined) { @@ -956,6 +904,8 @@ const make = Effect.gen(function* () { const orchestrationEngine = yield* OrchestrationEngineService; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const providerService = yield* ProviderService; + const projectionThreadMessages = yield* ProjectionThreadMessageRepository; + const projectionThreadProposedPlans = yield* ProjectionThreadProposedPlanRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const serverSettingsService = yield* ServerSettingsService; @@ -1013,21 +963,22 @@ const make = Effect.gen(function* () { ), ); - const resolveThreadDetail = Effect.fn("resolveThreadDetail")(function* ( + const resolveThreadRuntimeContext = Effect.fn("resolveThreadRuntimeContext")(function* ( threadId: ThreadId, - activityKinds: ReadonlyArray = [], ) { return yield* projectionSnapshotQuery - .getThreadDetailById(threadId, { activityKinds }) + .getThreadRuntimeContext(threadId) .pipe(Effect.map(Option.getOrUndefined)); }); - const resolveThreadRuntimeContext = Effect.fn("resolveThreadRuntimeContext")(function* ( + const getThreadMessageById = Effect.fn("getThreadMessageById")(function* ( threadId: ThreadId, + messageId: MessageId, ) { - return yield* projectionSnapshotQuery - .getThreadRuntimeContext(threadId) - .pipe(Effect.map(Option.getOrUndefined)); + const message = yield* projectionThreadMessages.getByMessageId({ messageId }); + return Option.filter(message, (entry) => entry.threadId === threadId).pipe( + Option.getOrUndefined, + ); }); const rememberAssistantMessageId = (threadId: ThreadId, turnId: TurnId, messageId: MessageId) => @@ -1195,15 +1146,6 @@ const make = Effect.gen(function* () { }), ); - const takeBufferedProposedPlan = (planId: string) => - Cache.getOption(bufferedProposedPlanById, planId).pipe( - Effect.flatMap((existingEntry) => - Cache.invalidate(bufferedProposedPlanById, planId).pipe( - Effect.as(Option.getOrUndefined(existingEntry)), - ), - ), - ); - const clearBufferedProposedPlan = (planId: string) => Cache.invalidate(bufferedProposedPlanById, planId); @@ -1357,83 +1299,45 @@ const make = Effect.gen(function* () { } }); - const upsertProposedPlan = (input: { - event: ProviderRuntimeEvent; - threadId: ThreadId; - threadProposedPlans: ReadonlyArray<{ - id: string; - createdAt: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - }>; - planId: string; - turnId?: TurnId; - planMarkdown: string | undefined; - createdAt: string; - updatedAt: string; - }) => - Effect.gen(function* () { - const planMarkdown = normalizeProposedPlanMarkdown(input.planMarkdown); - if (!planMarkdown) { - return; - } - - const existingPlan = findProposedPlanById(input.threadProposedPlans, input.planId); - yield* orchestrationEngine.dispatch({ - type: "thread.proposed-plan.upsert", - commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), - threadId: input.threadId, - proposedPlan: { - id: input.planId, - turnId: input.turnId ?? null, - planMarkdown, - implementedAt: existingPlan?.implementedAt ?? null, - implementationThreadId: existingPlan?.implementationThreadId ?? null, - createdAt: existingPlan?.createdAt ?? input.createdAt, - updatedAt: input.updatedAt, - }, - createdAt: input.updatedAt, - }); - }); - - const finalizeBufferedProposedPlan = (input: { + const finalizeBufferedProposedPlan = Effect.fn("finalizeBufferedProposedPlan")(function* (input: { event: ProviderRuntimeEvent; threadId: ThreadId; - threadProposedPlans: ReadonlyArray<{ - id: string; - createdAt: string; - implementedAt: string | null; - implementationThreadId: ThreadId | null; - }>; planId: string; turnId?: TurnId; fallbackMarkdown?: string; updatedAt: string; - }) => - Effect.gen(function* () { - const bufferedPlan = yield* takeBufferedProposedPlan(input.planId); - const bufferedMarkdown = normalizeProposedPlanMarkdown(bufferedPlan?.text); - const fallbackMarkdown = normalizeProposedPlanMarkdown(input.fallbackMarkdown); - const planMarkdown = bufferedMarkdown ?? fallbackMarkdown; - if (!planMarkdown) { - return; - } + }) { + const bufferedPlan = Option.getOrUndefined( + yield* Cache.getOption(bufferedProposedPlanById, input.planId), + ); + const planMarkdown = + normalizeProposedPlanMarkdown(bufferedPlan?.text) ?? + normalizeProposedPlanMarkdown(input.fallbackMarkdown); + if (!planMarkdown) return yield* clearBufferedProposedPlan(input.planId); - yield* upsertProposedPlan({ - event: input.event, + const existingPlan = Option.getOrUndefined( + yield* projectionThreadProposedPlans.getByPlanId({ threadId: input.threadId, - threadProposedPlans: input.threadProposedPlans, - planId: input.planId, - ...(input.turnId ? { turnId: input.turnId } : {}), + planId: OrchestrationProposedPlanId.make(input.planId), + }), + ); + yield* orchestrationEngine.dispatch({ + type: "thread.proposed-plan.upsert", + commandId: yield* providerCommandId(input.event, "proposed-plan-upsert"), + threadId: input.threadId, + proposedPlan: { + id: input.planId, + turnId: input.turnId ?? null, planMarkdown, - createdAt: - bufferedPlan?.createdAt && bufferedPlan.createdAt.length > 0 - ? bufferedPlan.createdAt - : input.updatedAt, + implementedAt: existingPlan?.implementedAt ?? null, + implementationThreadId: existingPlan?.implementationThreadId ?? null, + createdAt: existingPlan?.createdAt ?? (bufferedPlan?.createdAt || input.updatedAt), updatedAt: input.updatedAt, - }); - yield* clearBufferedProposedPlan(input.planId); + }, + createdAt: input.updatedAt, }); + yield* clearBufferedProposedPlan(input.planId); + }); const clearTurnStateForSession = (threadId: ThreadId) => Effect.gen(function* () { @@ -1538,8 +1442,13 @@ const make = Effect.gen(function* () { implementationThreadId: ThreadId, implementedAt: string, ) { - const sourceThread = yield* resolveThreadDetail(sourceThreadId); - const sourcePlan = sourceThread?.proposedPlans.find((entry) => entry.id === sourcePlanId); + const sourceThread = yield* resolveThreadRuntimeContext(sourceThreadId); + const sourcePlan = Option.getOrUndefined( + yield* projectionThreadProposedPlans.getByPlanId({ + threadId: sourceThreadId, + planId: sourcePlanId, + }), + ); if (!sourceThread || !sourcePlan || sourcePlan.implementedAt !== null) { return; } @@ -1550,9 +1459,12 @@ const make = Effect.gen(function* () { commandId: CommandId.make( `provider:source-proposed-plan-implemented:${implementationThreadId}:${commandUuid}`, ), - threadId: sourceThread.id, + threadId: sourceThreadId, proposedPlan: { - ...sourcePlan, + id: sourcePlan.planId, + turnId: sourcePlan.turnId, + planMarkdown: sourcePlan.planMarkdown, + createdAt: sourcePlan.createdAt, implementedAt, implementationThreadId, updatedAt: implementedAt, @@ -1571,16 +1483,6 @@ const make = Effect.gen(function* () { const thread = yield* resolveThreadRuntimeContext(event.threadId); if (!thread) return; - let loadedThreadDetail: OrchestrationThread | null | undefined; - const getLoadedThreadDetail = () => - Effect.gen(function* () { - if (loadedThreadDetail !== undefined) { - return loadedThreadDetail; - } - loadedThreadDetail = (yield* resolveThreadDetail(thread.id)) ?? null; - return loadedThreadDetail; - }); - const now = event.createdAt; const eventTurnId = toTurnId(event.turnId); const activeTurnId = thread.session?.activeTurnId ?? null; @@ -1800,7 +1702,11 @@ const make = Effect.gen(function* () { ? toTurnId(event.turnId) : undefined; if (pauseForUserTurnId) { - const detailedThread = yield* getLoadedThreadDetail(); + const hasProjectedMessage = yield* projectionThreadMessages.hasAssistantMessageForTurn({ + threadId: thread.id, + turnId: pauseForUserTurnId, + streamingOnly: true, + }); const assistantDeliveryMode: AssistantDeliveryMode = yield* Effect.map( serverSettingsService.getSettings, (settings) => (settings.enableLegacyTokenStreaming ? "streaming" : "buffered"), @@ -1831,11 +1737,7 @@ const make = Effect.gen(function* () { event.type === "request.opened" ? "assistant-delta-finalize-on-request-opened" : "assistant-delta-finalize-on-user-input-requested", - hasProjectedMessage: - detailedThread !== null && - hasAssistantMessageForTurn(detailedThread.messages, pauseForUserTurnId, { - streamingOnly: true, - }), + hasProjectedMessage, flushedMessageIds, }); } @@ -1864,19 +1766,24 @@ const make = Effect.gen(function* () { : undefined; if (assistantCompletion) { - const detailedThread = yield* getLoadedThreadDetail(); - const messages = detailedThread?.messages ?? []; const turnId = toTurnId(event.turnId); const activeAssistantMessageId = turnId ? yield* getActiveAssistantMessageIdForTurn(thread.id, turnId) : Option.none(); - const hasAssistantMessagesForTurn = - turnId !== undefined ? hasAssistantMessageForTurn(messages, turnId) : false; const assistantMessageId = Option.getOrElse( activeAssistantMessageId, () => assistantCompletion.messageId, ); - const existingAssistantMessage = findMessageById(messages, assistantMessageId); + const [existingAssistantMessage, hasAssistantMessagesForTurn] = yield* Effect.all([ + getThreadMessageById(thread.id, assistantMessageId), + turnId === undefined + ? Effect.succeed(false) + : projectionThreadMessages.hasAssistantMessageForTurn({ + threadId: thread.id, + turnId, + streamingOnly: false, + }), + ]); const shouldApplyFallbackCompletionText = !existingAssistantMessage || existingAssistantMessage.text.length === 0; @@ -1916,11 +1823,9 @@ const make = Effect.gen(function* () { } if (proposedPlanCompletion) { - const detailedThread = yield* getLoadedThreadDetail(); yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, - threadProposedPlans: detailedThread?.proposedPlans ?? [], planId: proposedPlanCompletion.planId, ...(proposedPlanCompletion.turnId ? { turnId: proposedPlanCompletion.turnId } : {}), fallbackMarkdown: proposedPlanCompletion.planMarkdown, @@ -1929,9 +1834,6 @@ const make = Effect.gen(function* () { } if (isTerminalTurn) { - const detailedThread = yield* getLoadedThreadDetail(); - const messages = detailedThread?.messages ?? []; - const proposedPlans = detailedThread?.proposedPlans ?? []; const turnId = toTurnId(event.turnId); if (turnId) { const userInputActivities = @@ -1979,16 +1881,20 @@ const make = Effect.gen(function* () { yield* Effect.forEach( assistantMessageIds, (assistantMessageId) => - finalizeAssistantMessage({ - event, - threadId: thread.id, - messageId: assistantMessageId, - turnId, - createdAt: now, - commandTag: "assistant-complete-finalize", - finalDeltaCommandTag: "assistant-delta-finalize-fallback", - hasProjectedMessage: findMessageById(messages, assistantMessageId) !== undefined, - }), + getThreadMessageById(thread.id, assistantMessageId).pipe( + Effect.flatMap((existingMessage) => + finalizeAssistantMessage({ + event, + threadId: thread.id, + messageId: assistantMessageId, + turnId, + createdAt: now, + commandTag: "assistant-complete-finalize", + finalDeltaCommandTag: "assistant-delta-finalize-fallback", + hasProjectedMessage: existingMessage !== undefined, + }), + ), + ), { concurrency: 1 }, ).pipe(Effect.asVoid); yield* clearAssistantMessageIdsForTurn(thread.id, turnId); @@ -1997,7 +1903,6 @@ const make = Effect.gen(function* () { yield* finalizeBufferedProposedPlan({ event, threadId: thread.id, - threadProposedPlans: proposedPlans, planId: proposedPlanIdForTurn(thread.id, turnId), turnId, updatedAt: now, @@ -2153,8 +2058,17 @@ const make = Effect.gen(function* () { if (event.type === "task.completed") { taskTitle = yield* lookupTaskDescription(thread.id, event.payload.taskId); if (!taskTitle) { - const threadDetail = yield* resolveThreadDetail(thread.id, TASK_TITLE_ACTIVITY_KINDS); - taskTitle = findTaskTitleInActivities(threadDetail?.activities, event.payload.taskId); + const taskActivity = yield* projectionThreadActivityRepository.getLatestTaskActivity({ + threadId: thread.id, + taskId: event.payload.taskId, + }); + taskTitle = findTaskTitleInActivities( + Option.match(taskActivity, { + onNone: () => undefined, + onSome: (activity) => [activity], + }), + event.payload.taskId, + ); } } @@ -2172,8 +2086,9 @@ const make = Effect.gen(function* () { DateTime.makeUnsafe(pendingTurnStart.value.requestedAt), ) ) { - const pendingMessage = (yield* getLoadedThreadDetail())?.messages.find( - (message) => message.id === pendingTurnStart.value.messageId, + const pendingMessage = yield* getThreadMessageById( + thread.id, + pendingTurnStart.value.messageId, ); if ( pendingMessage?.role === "user" && @@ -2192,11 +2107,13 @@ const make = Effect.gen(function* () { (activityEvent.payload.beforeTokens === undefined || activityEvent.payload.afterTokens === undefined) ) { - const threadDetail = yield* resolveThreadDetail(thread.id, [ - "context-window.updated", - "context-compaction", - ]); - const tokenCounts = compactedTokenCountsFromActivities(threadDetail?.activities); + const activities = yield* projectionThreadActivityRepository.listByThreadId({ + threadId: thread.id, + activityKinds: ["context-window.updated", "context-compaction"], + // Preserve the previous thread-detail read's context-history bound. + limit: 500, + }); + const tokenCounts = compactedTokenCountsFromActivities(activities); if (tokenCounts) { activityEvent = { ...activityEvent, @@ -2274,6 +2191,8 @@ export const ProviderRuntimeIngestionLive = Layer.effect( ProviderRuntimeIngestionService, make, ).pipe( - Layer.provide(ProjectionTurnRepositoryLive), Layer.provide(ProjectionThreadActivityRepositoryLive), + Layer.provide(ProjectionThreadMessageRepositoryLive), + Layer.provide(ProjectionThreadProposedPlanRepositoryLive), + Layer.provide(ProjectionTurnRepositoryLive), ); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 4f56a743d1d5..2f8285d8c1fd 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -1,4 +1,10 @@ -import { ProjectId, ThreadId, TurnId, ProviderInstanceId } from "@t3tools/contracts"; +import { + ProjectId, + ThreadId, + TurnId, + ProviderInstanceId, + OrchestrationProposedPlanId, +} from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -229,6 +235,46 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); + it.effect("reads only the requested plan in its thread", () => + Effect.gen(function* () { + const plans = yield* ProjectionThreadProposedPlanRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("plan-query-thread"); + const planId = OrchestrationProposedPlanId.make("plan-query-target"); + yield* plans.upsert({ + planId, + threadId, + turnId: null, + planMarkdown: "Keep this plan", + implementedAt: "2026-03-01T00:01:00.000Z", + implementationThreadId: ThreadId.make("implementation-thread"), + createdAt: "2026-03-01T00:00:00.000Z", + updatedAt: "2026-03-01T00:01:00.000Z", + }); + // An unrelated old row must not be loaded or decoded by the exact lookup. + yield* sql` + INSERT INTO projection_thread_proposed_plans ( + plan_id, thread_id, turn_id, plan_markdown, implemented_at, + implementation_thread_id, created_at, updated_at + ) VALUES ( + 'unrelated-plan', ${threadId}, NULL, '', NULL, NULL, + '2026-03-01T00:00:00.000Z', '2026-03-01T00:00:00.000Z' + ) + `; + const plan = Option.getOrThrow(yield* plans.getByPlanId({ threadId, planId })); + assert.equal(plan.planMarkdown, "Keep this plan"); + assert.equal(plan.implementedAt, "2026-03-01T00:01:00.000Z"); + assert.isTrue( + Option.isNone( + yield* plans.getByPlanId({ + threadId: ThreadId.make("another-thread"), + planId, + }), + ), + ); + }), + ); + it.effect("stores SQL NULL for missing project model options", () => Effect.gen(function* () { const projects = yield* ProjectionProjectRepository; diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts new file mode 100644 index 000000000000..d92ed97ea6fa --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.test.ts @@ -0,0 +1,100 @@ +import { EventId, ThreadId } from "@t3tools/contracts"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { ProjectionThreadActivityRepository } from "../Services/ProjectionThreadActivities.ts"; +import { ProjectionThreadActivityRepositoryLive } from "./ProjectionThreadActivities.ts"; +import { SqlitePersistenceMemory } from "./Sqlite.ts"; + +const layer = it.layer( + ProjectionThreadActivityRepositoryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)), +); + +layer("ProjectionThreadActivityRepository", (it) => { + it.effect("reads only the latest matching task activity", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadActivityRepository; + const sql = yield* SqlClient.SqlClient; + const threadId = ThreadId.make("thread-latest-task-activity"); + + yield* sql` + INSERT INTO projection_thread_activities ( + activity_id, thread_id, turn_id, tone, kind, summary, payload_json, sequence, created_at + ) + VALUES + ( + 'activity-task-unrelated-tool', ${threadId}, NULL, 'tool', 'tool.completed', + 'large tool output', 'not-json', 1, '2026-03-01T00:00:00.000Z' + ), + ( + 'activity-task-started', ${threadId}, NULL, 'info', 'task.started', + 'started', '{"taskId":"task-1","title":"Initial title"}', 2, + '2026-03-01T00:00:01.000Z' + ), + ( + 'activity-task-progress', ${threadId}, NULL, 'info', 'task.progress', + 'progress', '{"taskId":"task-1","title":"Updated title"}', 3, + '2026-03-01T00:00:02.000Z' + ), + ( + 'activity-task-other', ${threadId}, NULL, 'info', 'task.progress', + 'other', '{"taskId":"task-2","title":"Other title"}', 4, + '2026-03-01T00:00:03.000Z' + ) + `; + + yield* repository.upsert({ + activityId: EventId.make("activity-task-untitled"), + threadId, + turnId: null, + tone: "info", + kind: "task.progress", + summary: "Still running", + payload: { taskId: "task-1" }, + sequence: 5, + createdAt: "2026-03-01T00:00:04.000Z", + }); + yield* repository.upsert({ + activityId: EventId.make("activity-task-blank-title"), + threadId, + turnId: null, + tone: "info", + kind: "task.progress", + summary: "Still running", + payload: { taskId: "task-1", title: " \t\n\u00a0" }, + sequence: 6, + createdAt: "2026-03-01T00:00:05.000Z", + }); + + const recent = yield* repository.listByThreadId({ + threadId, + activityKinds: ["task.progress"], + limit: 2, + }); + assert.deepEqual( + recent.map((entry) => entry.activityId), + ["activity-task-untitled", "activity-task-blank-title"], + ); + + const activity = yield* repository.getLatestTaskActivity({ + threadId, + taskId: "task-1", + }); + assert.equal(activity._tag, "Some"); + if (activity._tag === "Some") { + assert.equal(activity.value.activityId, EventId.make("activity-task-progress")); + assert.deepEqual(activity.value.payload, { + taskId: "task-1", + title: "Updated title", + }); + } + + assert.equal( + (yield* repository.getLatestTaskActivity({ threadId, taskId: "missing" }))._tag, + "None", + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts index fa3c948e4f3d..9b6d44d170ee 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadActivities.ts @@ -3,6 +3,7 @@ import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import { NonNegativeInt } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -11,6 +12,7 @@ import { toPersistenceDecodeError, toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadActivitiesInput, ListProjectionThreadActivitiesInput, + GetLatestProjectionThreadTaskActivityInput, ProjectionThreadActivity, ProjectionThreadActivityRepository, type ProjectionThreadActivityRepositoryShape, @@ -23,10 +25,10 @@ const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( }), ); -const mapActivityRows = ( - rows: ReadonlyArray>, -): ReadonlyArray => - rows.map((row) => ({ +function toProjectionThreadActivity( + row: Schema.Schema.Type, +): ProjectionThreadActivity { + return { activityId: row.activityId, threadId: row.threadId, turnId: row.turnId, @@ -36,7 +38,8 @@ const mapActivityRows = ( payload: row.payload, ...(row.sequence !== null ? { sequence: row.sequence } : {}), createdAt: row.createdAt, - })); + }; +} function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: string) { return (cause: unknown) => @@ -45,6 +48,10 @@ function toPersistenceSqlOrDecodeError(sqlOperation: string, decodeOperation: st : toPersistenceSqlError(sqlOperation)(cause); } +// Match String.trim so blank saved titles cannot hide an earlier task name. +const taskTitleWhitespace = + "\u0009\u000a\u000b\u000c\u000d\u0020\u00a0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"; + const makeProjectionThreadActivityRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -90,7 +97,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { const listProjectionThreadActivityRows = SqlSchema.findAll({ Request: ListProjectionThreadActivitiesInput, Result: ProjectionThreadActivityDbRowSchema, - execute: ({ threadId }) => + execute: ({ threadId, activityKinds, limit }) => sql` SELECT activity_id AS "activityId", @@ -102,8 +109,14 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { payload_json AS "payload", sequence, created_at AS "createdAt" - FROM projection_thread_activities - WHERE thread_id = ${threadId} + FROM ( + SELECT * + FROM projection_thread_activities + WHERE thread_id = ${threadId} + ${activityKinds === undefined ? sql`` : sql`AND ${sql.in("kind", activityKinds)}`} + ORDER BY sequence DESC, created_at DESC, activity_id DESC + ${limit === undefined ? sql`` : sql`LIMIT ${limit}`} + ) AS recent_activities ORDER BY CASE WHEN sequence IS NULL THEN 0 ELSE 1 END ASC, sequence ASC, @@ -142,6 +155,40 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { `, }); + const getLatestProjectionThreadTaskActivityRow = SqlSchema.findOneOption({ + Request: GetLatestProjectionThreadTaskActivityInput, + Result: ProjectionThreadActivityDbRowSchema, + execute: ({ threadId, taskId }) => + 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 ('task.started', 'task.progress') + AND json_extract(payload_json, '$.taskId') = ${taskId} + AND length(trim( + CASE + WHEN json_type(payload_json, '$.title') = 'text' + THEN json_extract(payload_json, '$.title') + WHEN kind = 'task.started' AND json_type(payload_json, '$.detail') = 'text' + THEN json_extract(payload_json, '$.detail') + ELSE '' + END, + ${taskTitleWhitespace} + )) > 0 + ORDER BY sequence DESC, created_at DESC, activity_id DESC + LIMIT 1 + `, + }); + const deleteProjectionThreadActivityRows = SqlSchema.void({ Request: DeleteProjectionThreadActivitiesInput, execute: ({ threadId }) => @@ -169,7 +216,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listByThreadId:decodeRows", ), ), - Effect.map(mapActivityRows), + Effect.map((rows) => rows.map(toProjectionThreadActivity)), ); const listUserInputLifecycleByThreadId: ProjectionThreadActivityRepositoryShape["listUserInputLifecycleByThreadId"] = @@ -181,9 +228,22 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { "ProjectionThreadActivityRepository.listUserInputLifecycleByThreadId:decodeRows", ), ), - Effect.map(mapActivityRows), + Effect.map((rows) => rows.map(toProjectionThreadActivity)), ); + const getLatestTaskActivity: ProjectionThreadActivityRepositoryShape["getLatestTaskActivity"] = ( + input, + ) => + getLatestProjectionThreadTaskActivityRow(input).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionThreadActivityRepository.getLatestTaskActivity:query", + "ProjectionThreadActivityRepository.getLatestTaskActivity:decodeRow", + ), + ), + Effect.map(Option.map(toProjectionThreadActivity)), + ); + const deleteByThreadId: ProjectionThreadActivityRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadActivityRows(input).pipe( Effect.mapError( @@ -195,6 +255,7 @@ const makeProjectionThreadActivityRepository = Effect.gen(function* () { upsert, listByThreadId, listUserInputLifecycleByThreadId, + getLatestTaskActivity, deleteByThreadId, } satisfies ProjectionThreadActivityRepositoryShape; }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index d4a70af59be5..12f4db91fe0b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -1,4 +1,4 @@ -import { MessageId, ThreadId } from "@t3tools/contracts"; +import { MessageId, ThreadId, TurnId } from "@t3tools/contracts"; import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -231,4 +231,49 @@ layer("ProjectionThreadMessageRepository", (it) => { assert.deepEqual(rows[0]?.attachments, []); }), ); + + it.effect("checks assistant turn state without hydrating message text", () => + Effect.gen(function* () { + const repository = yield* ProjectionThreadMessageRepository; + const threadId = ThreadId.make("thread-assistant-turn-state"); + const turnId = TurnId.make("turn-assistant-state"); + const createdAt = "2026-03-01T00:00:00.000Z"; + + yield* repository.upsert({ + messageId: MessageId.make("message-assistant-turn-state"), + threadId, + turnId, + role: "assistant", + text: "large text that the existence query must not select", + isStreaming: false, + createdAt, + updatedAt: createdAt, + }); + + assert.equal( + yield* repository.hasAssistantMessageForTurn({ + threadId, + turnId, + streamingOnly: false, + }), + true, + ); + assert.equal( + yield* repository.hasAssistantMessageForTurn({ + threadId, + turnId, + streamingOnly: true, + }), + false, + ); + assert.equal( + yield* repository.hasAssistantMessageForTurn({ + threadId, + turnId: TurnId.make("turn-assistant-state-missing"), + streamingOnly: false, + }), + false, + ); + }), + ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index be20fb37f36d..eae7189de5b2 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -11,6 +11,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; import { AppendStreamingProjectionThreadMessage, GetProjectionThreadMessageInput, + HasProjectionThreadAssistantMessageInput, ProjectionThreadMessageRepository, type ProjectionThreadMessageRepositoryShape, DeleteProjectionThreadMessagesInput, @@ -24,6 +25,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), }), ); +const ProjectionThreadMessageExistsDbRowSchema = Schema.Struct({ exists: Schema.Number }); function toProjectionThreadMessage( row: Schema.Schema.Type, @@ -161,6 +163,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const hasProjectionThreadAssistantMessageRow = SqlSchema.findOne({ + Request: HasProjectionThreadAssistantMessageInput, + Result: ProjectionThreadMessageExistsDbRowSchema, + execute: ({ threadId, turnId, streamingOnly }) => + sql` + SELECT EXISTS ( + SELECT 1 + FROM projection_thread_messages + WHERE thread_id = ${threadId} + AND turn_id = ${turnId} + AND role = 'assistant' + AND (${streamingOnly ? 1 : 0} = 0 OR is_streaming = 1) + LIMIT 1 + ) AS "exists" + `, + }); + const listProjectionThreadMessageRows = SqlSchema.findAll({ Request: ListProjectionThreadMessagesInput, Result: ProjectionThreadMessageDbRowSchema, @@ -224,6 +243,17 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map(Option.map(toProjectionThreadMessage)), ); + const hasAssistantMessageForTurn: ProjectionThreadMessageRepositoryShape["hasAssistantMessageForTurn"] = + (input) => + hasProjectionThreadAssistantMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadMessageRepository.hasAssistantMessageForTurn:query", + ), + ), + Effect.map((row) => row.exists === 1), + ); + const listByThreadId: ProjectionThreadMessageRepositoryShape["listByThreadId"] = (input) => listProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -253,6 +283,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { upsert, appendStreaming, getByMessageId, + hasAssistantMessageForTurn, listByThreadId, getLatestUserMessageAt, deleteByThreadId, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts index 38b113a1b703..816ef0a055dd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadProposedPlans.ts @@ -8,6 +8,7 @@ import { toPersistenceSqlError } from "../Errors.ts"; import { DeleteProjectionThreadProposedPlansInput, HasActionableProjectionThreadProposedPlanInput, + GetProjectionThreadProposedPlanInput, ListProjectionThreadProposedPlansInput, ProjectionThreadProposedPlan, ProjectionThreadProposedPlanRepository, @@ -52,6 +53,24 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { `, }); + const getProjectionThreadProposedPlanRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadProposedPlanInput, + Result: ProjectionThreadProposedPlan, + execute: ({ threadId, planId }) => sql` + SELECT + plan_id AS "planId", + thread_id AS "threadId", + turn_id AS "turnId", + plan_markdown AS "planMarkdown", + implemented_at AS "implementedAt", + implementation_thread_id AS "implementationThreadId", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_proposed_plans + WHERE thread_id = ${threadId} AND plan_id = ${planId} + `, + }); + const listProjectionThreadProposedPlanRows = SqlSchema.findAll({ Request: ListProjectionThreadProposedPlansInput, Result: ProjectionThreadProposedPlan, @@ -133,6 +152,13 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { Effect.mapError(toPersistenceSqlError("ProjectionThreadProposedPlanRepository.upsert:query")), ); + const getByPlanId: ProjectionThreadProposedPlanRepositoryShape["getByPlanId"] = (input) => + getProjectionThreadProposedPlanRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadProposedPlanRepository.getByPlanId:query"), + ), + ); + const listByThreadId: ProjectionThreadProposedPlanRepositoryShape["listByThreadId"] = (input) => listProjectionThreadProposedPlanRows(input).pipe( Effect.mapError( @@ -153,6 +179,7 @@ const makeProjectionThreadProposedPlanRepository = Effect.gen(function* () { upsert, listByThreadId, hasActionableByThreadId, + getByPlanId, deleteByThreadId, } satisfies ProjectionThreadProposedPlanRepositoryShape; }); diff --git a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts index e8c1e47a328b..85e9d368df4c 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadActivities.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadActivities.ts @@ -17,6 +17,7 @@ import { import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -35,9 +36,18 @@ export type ProjectionThreadActivity = typeof ProjectionThreadActivity.Type; export const ListProjectionThreadActivitiesInput = Schema.Struct({ threadId: ThreadId, + activityKinds: Schema.optional(Schema.Array(Schema.String)), + limit: Schema.optional(NonNegativeInt), }); export type ListProjectionThreadActivitiesInput = typeof ListProjectionThreadActivitiesInput.Type; +export const GetLatestProjectionThreadTaskActivityInput = Schema.Struct({ + threadId: ThreadId, + taskId: Schema.String, +}); +export type GetLatestProjectionThreadTaskActivityInput = + typeof GetLatestProjectionThreadTaskActivityInput.Type; + export const DeleteProjectionThreadActivitiesInput = Schema.Struct({ threadId: ThreadId, }); @@ -61,7 +71,7 @@ export interface ProjectionThreadActivityRepositoryShape { * List projected thread activity rows for a thread. * * Returned in ascending runtime sequence order (or creation order when - * sequence is unavailable). + * sequence is unavailable). A limit selects the newest matching rows. */ readonly listByThreadId: ( input: ListProjectionThreadActivitiesInput, @@ -76,6 +86,13 @@ export interface ProjectionThreadActivityRepositoryShape { input: ListProjectionThreadActivitiesInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Read the latest task-start or task-progress activity with a usable title. + */ + readonly getLatestTaskActivity: ( + input: GetLatestProjectionThreadTaskActivityInput, + ) => Effect.Effect, ProjectionRepositoryError>; + /** * Delete projected thread activity rows by thread. */ diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index a41737564382..a7e258ad5dc0 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -51,6 +51,14 @@ export const GetProjectionThreadMessageInput = Schema.Struct({ }); export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageInput.Type; +export const HasProjectionThreadAssistantMessageInput = Schema.Struct({ + threadId: ThreadId, + turnId: TurnId, + streamingOnly: Schema.Boolean, +}); +export type HasProjectionThreadAssistantMessageInput = + typeof HasProjectionThreadAssistantMessageInput.Type; + export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); @@ -81,6 +89,13 @@ export interface ProjectionThreadMessageRepositoryShape { input: GetProjectionThreadMessageInput, ) => Effect.Effect, ProjectionRepositoryError>; + /** + * Check for an assistant message in a turn without hydrating message text. + */ + readonly hasAssistantMessageForTurn: ( + input: HasProjectionThreadAssistantMessageInput, + ) => Effect.Effect; + /** * List projected thread messages for a thread. * diff --git a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts index c8724efca9e3..db8a36015505 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts @@ -8,6 +8,7 @@ import { import * as Schema from "effect/Schema"; import * as Context from "effect/Context"; import type * as Effect from "effect/Effect"; +import type * as Option from "effect/Option"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -36,6 +37,12 @@ export const HasActionableProjectionThreadProposedPlanInput = Schema.Struct({ export type HasActionableProjectionThreadProposedPlanInput = typeof HasActionableProjectionThreadProposedPlanInput.Type; +export const GetProjectionThreadProposedPlanInput = Schema.Struct({ + threadId: ThreadId, + planId: OrchestrationProposedPlanId, +}); +export type GetProjectionThreadProposedPlanInput = typeof GetProjectionThreadProposedPlanInput.Type; + export const DeleteProjectionThreadProposedPlansInput = Schema.Struct({ threadId: ThreadId, }); @@ -46,6 +53,10 @@ export interface ProjectionThreadProposedPlanRepositoryShape { readonly upsert: ( proposedPlan: ProjectionThreadProposedPlan, ) => Effect.Effect; + /** Read one plan without loading the thread's other plans. */ + readonly getByPlanId: ( + input: GetProjectionThreadProposedPlanInput, + ) => Effect.Effect, ProjectionRepositoryError>; readonly listByThreadId: ( input: ListProjectionThreadProposedPlansInput, ) => Effect.Effect, ProjectionRepositoryError>; From 5fa35d211682ee02e34fba0711838ca431ed003b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 02:59:21 -0700 Subject: [PATCH 220/320] refactor(server): let adapters declare context compaction (#10112) Co-authored-by: Claude Fable 5.1 --- .../src/provider/Layers/AntigravityAdapter.ts | 1 + .../src/provider/Layers/ClaudeAdapter.ts | 1 + .../src/provider/Layers/CodexAdapter.test.ts | 3 +- .../src/provider/Layers/CodexAdapter.ts | 16 +-- .../src/provider/Layers/CursorAdapter.ts | 1 + .../server/src/provider/Layers/GrokAdapter.ts | 1 + .../provider/Layers/OpenCodeAdapter.test.ts | 3 +- .../src/provider/Layers/OpenCodeAdapter.ts | 10 +- .../provider/Layers/ProviderService.test.ts | 129 +++++++++++++++++- .../src/provider/Layers/ProviderService.ts | 35 +++-- .../src/provider/Services/ProviderAdapter.ts | 21 ++- 11 files changed, 185 insertions(+), 36 deletions(-) diff --git a/apps/server/src/provider/Layers/AntigravityAdapter.ts b/apps/server/src/provider/Layers/AntigravityAdapter.ts index aa7d4c6a6755..97925cd4912d 100644 --- a/apps/server/src/provider/Layers/AntigravityAdapter.ts +++ b/apps/server/src/provider/Layers/AntigravityAdapter.ts @@ -1245,6 +1245,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", supportsConversationRollback: false }, + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 2f0d15e281df..1ecde618e191 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -5075,6 +5075,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( capabilities: { sessionModelSwitch: "in-session", }, + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 4676d780a530..ef6e97d8993d 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -353,7 +353,8 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { Stream.runHead, Effect.forkChild, ); - yield* adapter.compactThread!(threadId); + NodeAssert.ok(adapter.compaction?.type === "native"); + yield* adapter.compaction.start(threadId); yield* runtime.emit({ id: asEventId("evt-compaction-item-completed"), kind: "notification", diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index d1981b33d47d..5e2244336afc 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -2504,14 +2504,12 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ), ); - const compactThread: NonNullable = Effect.fn("compactThread")( - function* (threadId) { - const session = yield* requireSession(threadId); - yield* session.runtime.compactThread.pipe( - Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), - ); - }, - ); + const compactThread = Effect.fn("compactThread")(function* (threadId: ThreadId) { + const session = yield* requireSession(threadId); + yield* session.runtime.compactThread.pipe( + Effect.mapError((cause) => mapCodexRuntimeError(threadId, "thread/compact/start", cause)), + ); + }); const readThread: CodexAdapterShape["readThread"] = (threadId) => requireSession(threadId).pipe( @@ -2658,7 +2656,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( }, startSession, sendTurn, - compactThread, + compaction: { type: "native", start: compactThread }, interruptTurn, readThread, rollbackThread, diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index a8aea90e8972..1ed9648a50b6 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -1212,6 +1212,7 @@ export function makeCursorAdapter( return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, + compaction: { type: "slash-command", command: "/compress" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index dae7c2ca08f6..25188adcffcc 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -2128,6 +2128,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte return { provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session" }, + compaction: { type: "slash-command", command: "/compact" }, startSession, sendTurn, interruptTurn, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index c8a7d12af276..ee5767f9d356 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -1080,7 +1080,8 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { threadId, runtimeMode: "full-access", }); - yield* adapter.compactThread!( + NodeAssert.ok(adapter.compaction?.type === "native"); + yield* adapter.compaction.start( threadId, createModelSelection(ProviderInstanceId.make("opencode"), "openai/gpt-5"), ); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 38444be4ad19..742ee9b86d6a 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -4,6 +4,7 @@ import { ProviderDriverKind, ProviderInstanceId, type ProviderRuntimeEvent, + type ProviderSendTurnInput, type ProviderSession, RuntimeItemId, RuntimeRequestId, @@ -3426,9 +3427,10 @@ export function makeOpenCodeAdapter( ); }); - const compactThread: NonNullable = Effect.fn( - "compactThread", - )(function* (threadId, requestedModelSelection) { + const compactThread = Effect.fn("compactThread")(function* ( + threadId: ThreadId, + requestedModelSelection?: ProviderSendTurnInput["modelSelection"], + ) { const context = yield* ensureSessionContext(sessions, threadId); yield* awaitOpenCodeContextReady(context); const modelSelection = @@ -3843,7 +3845,7 @@ export function makeOpenCodeAdapter( }, startSession, sendTurn, - compactThread, + compaction: { type: "native", start: compactThread }, interruptTurn, respondToRequest, respondToUserInput, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 0342f3ca79e3..4d17aabaa5f5 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -190,7 +190,7 @@ function makeFakeCodexAdapter( Effect.void, ); - const compactThread = vi.fn((threadId: ThreadId) => + const compactThread = vi.fn((threadId: ThreadId): Effect.Effect => Effect.sync(() => emit({ type: "thread.state.changed", @@ -278,7 +278,13 @@ function makeFakeCodexAdapter( }, startSession, sendTurn, - ...(provider === CODEX_DRIVER ? { compactThread } : {}), + ...(provider === CODEX_DRIVER + ? { compaction: { type: "native", start: compactThread } } + : provider === CURSOR_DRIVER + ? { compaction: { type: "slash-command", command: "/compress" } } + : provider === CLAUDE_AGENT_DRIVER + ? { compaction: { type: "slash-command", command: "/compact" } } + : {}), interruptTurn, respondToRequest, respondToUserInput, @@ -981,6 +987,125 @@ it.effect("ProviderServiceLive rejects new sessions for disabled custom instance const routing = makeProviderServiceLayer(); +const customCompactionDriver = ProviderDriverKind.make("custom-compaction-provider"); +const nativeCompactionInstanceId = ProviderInstanceId.make("native-compaction"); +const slashCompactionInstanceId = ProviderInstanceId.make("slash-compaction"); +const unsupportedCompactionInstanceId = ProviderInstanceId.make("unsupported-compaction"); +const customNativeCompaction = makeFakeCodexAdapter(customCompactionDriver); +const customSlashCompaction = makeFakeCodexAdapter(customCompactionDriver); +const unsupportedCompaction = makeFakeCodexAdapter(customCompactionDriver); +const declaredCompaction = makeProviderServiceLayer({ + registry: makeStaticInstanceRegistry([ + [ + nativeCompactionInstanceId, + { + ...customNativeCompaction.adapter, + compaction: { type: "native", start: customNativeCompaction.compactThread }, + }, + ], + [ + slashCompactionInstanceId, + { + ...customSlashCompaction.adapter, + compaction: { type: "slash-command", command: "/reduce-context" }, + }, + ], + [unsupportedCompactionInstanceId, unsupportedCompaction.adapter], + ]), +}); + +declaredCompaction.layer("ProviderService declared compaction", (it) => { + it.effect("starts declared native compaction instead of sending a prompt", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("custom-native-compaction"); + const requestId = MessageId.make("custom-native-request"); + yield* provider.startSession(threadId, { + providerInstanceId: nativeCompactionInstanceId, + threadId, + runtimeMode: "full-access", + }); + const compactedEventFiber = yield* provider.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.state.changed", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + yield* advanceTestClock(50); + yield* provider.compactThread(threadId, undefined, requestId); + const compacted = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); + assert.equal(compacted.requestId, String(requestId)); + assert.equal(customNativeCompaction.compactThread.mock.calls.length, 1); + assert.equal(customNativeCompaction.sendTurn.mock.calls.length, 0); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("sends the declared slash command as the compaction turn", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("custom-slash-compaction"); + const requestId = MessageId.make("custom-slash-request"); + const modelSelection = createModelSelection(slashCompactionInstanceId, "custom-model"); + yield* provider.startSession(threadId, { + providerInstanceId: slashCompactionInstanceId, + threadId, + runtimeMode: "full-access", + }); + const compactedEventFiber = yield* provider.streamEvents.pipe( + Stream.filter( + (event) => event.threadId === threadId && event.type === "thread.state.changed", + ), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + const compactFiber = yield* provider + .compactThread(threadId, modelSelection, requestId) + .pipe(Effect.forkChild); + yield* advanceTestClock(50); + customSlashCompaction.emit({ + type: "turn.completed", + eventId: asEventId("custom-slash-completed"), + provider: customCompactionDriver, + createdAt: "2026-01-01T00:00:01.000Z", + threadId, + turnId: asTurnId(`turn-${threadId}`), + payload: { state: "completed" }, + }); + yield* Fiber.join(compactFiber); + const compacted = Option.getOrThrow(yield* Fiber.join(compactedEventFiber)); + assert.equal(compacted.requestId, String(requestId)); + assert.equal(customSlashCompaction.compactThread.mock.calls.length, 0); + assert.equal(customSlashCompaction.sendTurn.mock.calls.length, 1); + assert.equal(customSlashCompaction.sendTurn.mock.calls[0]?.[0].input, "/reduce-context"); + assert.deepEqual( + customSlashCompaction.sendTurn.mock.calls[0]?.[0].modelSelection, + modelSelection, + ); + yield* provider.stopSession({ threadId }); + }), + ); + + it.effect("rejects compaction for adapters without a declared strategy", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const threadId = asThreadId("custom-unsupported-compaction"); + yield* provider.startSession(threadId, { + providerInstanceId: unsupportedCompactionInstanceId, + threadId, + runtimeMode: "full-access", + }); + const failure = yield* provider.compactThread(threadId).pipe(Effect.flip); + assert.instanceOf(failure, ProviderValidationError); + assert.include(failure.message, "does not support context compaction"); + assert.equal(unsupportedCompaction.sendTurn.mock.calls.length, 0); + assert.equal(unsupportedCompaction.compactThread.mock.calls.length, 0); + yield* provider.stopSession({ threadId }); + }), + ); +}); + const antigravityDriver = ProviderDriverKind.make("antigravity"); const replacementAntigravity = makeFakeCodexAdapter(antigravityDriver); const originalAntigravityInstanceId = ProviderInstanceId.make("antigravity-personal"); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index d9cac46ec4d9..2b2719faabd3 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -76,6 +76,9 @@ import * as ServerSettings from "../../serverSettings.ts"; import * as ProjectionSnapshotQuery from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; const isModelSelection = Schema.is(ModelSelection); +/** How long a manual context compaction may run before ProviderService gives up on it. */ +const COMPACTION_COMPLETION_TIMEOUT = "10 minutes"; + interface PendingCompaction { readonly completion: Deferred.Deferred; readonly native: boolean; @@ -1522,18 +1525,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( "provider.thread_id": threadId, }); yield* McpSessionRegistry.touchActiveMcpThread(threadId); - const nativeCompaction = routed.adapter.compactThread; + const compaction = routed.adapter.compaction; + if (compaction === undefined) { + return yield* toValidationError( + "ProviderService.compactThread", + `Provider '${routed.adapter.provider}' does not support context compaction.`, + ); + } const completion = yield* Deferred.make(); const pending: PendingCompaction = { completion, - native: nativeCompaction !== undefined, + native: compaction.type === "native", providerInstanceId: routed.instanceId, requestId, earlyEvents: [], compactedEventObserved: false, expectedTurnId: undefined, }; - if (nativeCompaction !== undefined && timedOutNativeCompactions.has(threadId)) { + if (compaction.type === "native" && timedOutNativeCompactions.has(threadId)) { return yield* new ProviderAdapterRequestError({ provider: routed.adapter.provider, method: "thread/compact", @@ -1558,14 +1567,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( pendingCompactions.delete(threadId); } }); - const nativeCompletionTimeout = - routed.adapter.provider === "codex" || routed.adapter.provider === "opencode" - ? "10 minutes" - : "30 seconds"; const awaitNativeCompaction = (start: Effect.Effect) => start.pipe( Effect.andThen(Deferred.await(completion)), - Effect.timeout(nativeCompletionTimeout), + Effect.timeout(COMPACTION_COMPLETION_TIMEOUT), Effect.catchTag("TimeoutError", (cause) => Effect.sync(() => { timedOutNativeCompactions.add(threadId); @@ -1575,7 +1580,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( new ProviderAdapterRequestError({ provider: routed.adapter.provider, method: "thread/compact", - detail: `Provider did not report completed context compaction within ${nativeCompletionTimeout}.`, + detail: `Provider did not report completed context compaction within ${COMPACTION_COMPLETION_TIMEOUT}.`, cause, }), ), @@ -1584,24 +1589,24 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); const awaitFallbackCompaction = Deferred.await(completion).pipe( - Effect.timeout("10 minutes"), + Effect.timeout(COMPACTION_COMPLETION_TIMEOUT), Effect.mapError( (cause) => new ProviderAdapterRequestError({ provider: routed.adapter.provider, method: "turn/start", - detail: "Provider did not finish context compaction within 10 minutes.", + detail: `Provider did not finish context compaction within ${COMPACTION_COMPLETION_TIMEOUT}.`, cause, }), ), ); const terminal = yield* ( - nativeCompaction - ? awaitNativeCompaction(nativeCompaction(routed.threadId, modelSelection)) + compaction.type === "native" + ? awaitNativeCompaction(compaction.start(routed.threadId, modelSelection)) : Effect.gen(function* () { const turn = yield* sendTurn({ threadId, - input: routed.adapter.provider === "cursor" ? "/compress" : "/compact", + input: compaction.command, ...(modelSelection !== undefined ? { modelSelection } : {}), }).pipe( Effect.onError(() => @@ -1621,7 +1626,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( if (terminal !== "completed") { return yield* new ProviderAdapterRequestError({ provider: routed.adapter.provider, - method: nativeCompaction ? "thread/compact" : "turn/start", + method: compaction.type === "native" ? "thread/compact" : "turn/start", detail: `Context compaction ended with ${terminal}.`, }); } diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 0e4d696335b0..c9b62fd79525 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -27,6 +27,21 @@ import type * as Stream from "effect/Stream"; export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; +/** + * How ProviderService runs manual context compaction for an adapter. + * Native adapters expose a start call and must emit a compacted thread state + * when they finish. Slash-command adapters get the command sent as a turn. + */ +export type ProviderCompaction = + | { + readonly type: "native"; + readonly start: ( + threadId: ThreadId, + modelSelection?: ProviderSendTurnInput["modelSelection"], + ) => Effect.Effect; + } + | { readonly type: "slash-command"; readonly command: `/${string}` }; + export interface ProviderAdapterCapabilities { /** * Declares whether changing the model on an existing session is supported. @@ -70,10 +85,8 @@ export interface ProviderAdapterShape { input: ProviderSendTurnInput, ) => Effect.Effect; - readonly compactThread?: ( - threadId: ThreadId, - modelSelection?: ProviderSendTurnInput["modelSelection"], - ) => Effect.Effect; + /** Omitted when this adapter does not support manual context compaction. */ + readonly compaction?: ProviderCompaction; /** * Interrupt an active turn. From 223ff4490f764a74ff911589e97b9bbcd595fee8 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sun, 6 Sep 2026 04:06:15 -0700 Subject: [PATCH 221/320] fix(server): link thread PRs without an open client (#10101) Co-authored-by: Claude Fable 5.1 --- apps/mobile/src/features/home/HomeScreen.tsx | 22 +- .../HardwareKeyboardCommandProvider.tsx | 37 +- .../threads/ThreadNavigationSidebar.tsx | 15 - .../features/threads/thread-list-items.tsx | 3 +- .../features/threads/thread-list-v2-items.tsx | 3 +- apps/mobile/src/state/use-thread-pr.ts | 69 +- apps/mobile/src/state/use-thread-selection.ts | 1 + .../OrchestrationEngineHarness.integration.ts | 7 + apps/server/src/git/GitManager.test.ts | 235 ++++++- apps/server/src/git/GitManager.ts | 66 +- .../Layers/OrchestrationEngine.test.ts | 211 ++++++ .../Layers/OrchestrationEngine.ts | 17 + .../Layers/OrchestrationReactor.test.ts | 11 + .../Layers/OrchestrationReactor.ts | 3 + .../Layers/ProjectionPipeline.test.ts | 92 +++ .../Layers/ProjectionPipeline.ts | 4 + .../Layers/ProjectionSnapshotQuery.test.ts | 36 + .../Layers/ProjectionSnapshotQuery.ts | 11 + .../ThreadPullRequestReactor.test.ts | 613 ++++++++++++++++++ .../orchestration/ThreadPullRequestReactor.ts | 371 +++++++++++ .../ThreadSettlementReactor.test.ts | 126 +++- .../orchestration/ThreadSettlementReactor.ts | 94 +-- apps/server/src/orchestration/decider.ts | 59 ++ .../src/orchestration/projector.test.ts | 62 ++ apps/server/src/orchestration/projector.ts | 4 + .../Layers/ProjectionRepositories.test.ts | 18 +- .../persistence/Layers/ProjectionThreads.ts | 6 + apps/server/src/persistence/Migrations.ts | 2 + .../048_ProjectionThreadBranchPullRequest.ts | 16 + .../persistence/Services/ProjectionThreads.ts | 1 + .../RepositoryIdentityResolver.test.ts | 65 +- .../src/project/RepositoryIdentityResolver.ts | 9 +- apps/server/src/server.ts | 2 + .../BranchToolbarBranchSelector.tsx | 19 +- .../web/src/components/ChatView.logic.test.ts | 35 + apps/web/src/components/ChatView.logic.ts | 19 + apps/web/src/components/ChatView.tsx | 228 +------ apps/web/src/components/CommandPalette.tsx | 43 +- apps/web/src/components/LegacySidebar.tsx | 55 +- apps/web/src/components/Sidebar.tsx | 63 +- .../components/ThreadStatusIndicators.test.ts | 525 --------------- .../src/components/ThreadStatusIndicators.tsx | 311 +-------- .../pullRequest/PullRequestDetailPanel.tsx | 12 - docs/user/thread-sidebar.md | 7 +- .../src/state/threadReducer.test.ts | 97 +-- .../client-runtime/src/state/threadReducer.ts | 4 + packages/contracts/src/orchestration.test.ts | 42 ++ packages/contracts/src/orchestration.ts | 21 + packages/shared/src/threadReference.test.ts | 19 +- packages/shared/src/threadReference.ts | 4 +- 50 files changed, 2309 insertions(+), 1486 deletions(-) create mode 100644 apps/server/src/orchestration/ThreadPullRequestReactor.test.ts create mode 100644 apps/server/src/orchestration/ThreadPullRequestReactor.ts create mode 100644 apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 4c41ce2bf150..798a6a840c94 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -416,14 +416,6 @@ export function HomeScreen(props: HomeScreenProps) { [threadListV2Enabled, projectGroups, effectiveGroupDisplayStates, hasSearchQuery], ); - const projectCwdByKey = useMemo(() => { - const map = new Map(); - for (const project of props.projects) { - map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); - } - return map; - }, [props.projects]); - const projectByKey = useMemo(() => { const map = new Map(); for (const project of props.projects) { @@ -850,9 +842,6 @@ export function HomeScreen(props: HomeScreenProps) { onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} onMovePinnedThread={handleMovePinnedThread} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? null - } onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> @@ -875,7 +864,6 @@ export function HomeScreen(props: HomeScreenProps) { machineByEnvironmentId, pinReorderEnvironmentIds, projectByKey, - projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, props.onSelectPendingTask, @@ -903,7 +891,6 @@ export function HomeScreen(props: HomeScreenProps) { const v2ExtraData = useMemo( () => ({ projectByKey, - projectCwdByKey, projectTitleByProjectKey: v2ProjectTitleByProjectKey, serverConfigs, savedConnectionsById: props.savedConnectionsById, @@ -913,7 +900,6 @@ export function HomeScreen(props: HomeScreenProps) { }), [ projectByKey, - projectCwdByKey, props.searchQuery, props.savedConnectionsById, serverConfigs, @@ -925,12 +911,11 @@ export function HomeScreen(props: HomeScreenProps) { const extraData = useMemo( () => ({ - projectCwdByKey, savedConnectionsById: props.savedConnectionsById, searchQuery: props.searchQuery, threadSearchMatchByKey, }), - [projectCwdByKey, props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], + [props.savedConnectionsById, props.searchQuery, threadSearchMatchByKey], ); const renderItem = useCallback( @@ -982,10 +967,6 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? - null - } isLast={item.isLast} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -1021,7 +1002,6 @@ export function HomeScreen(props: HomeScreenProps) { handleSwipeableWillOpen, handleRegenerateThreadTitle, machineByEnvironmentId, - projectCwdByKey, props.onArchiveThread, props.onDeletePendingTask, props.onDeleteThread, diff --git a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx index 909bbcf5a762..ad29e0f32a1a 100644 --- a/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx +++ b/apps/mobile/src/features/keyboard/HardwareKeyboardCommandProvider.tsx @@ -12,10 +12,8 @@ import { import { tryCopyTextWithHaptic } from "../../lib/copyTextWithHaptic"; import { T3KeyboardCommands } from "../../native/T3KeyboardCommands"; -import { useProject, useThreadShell } from "../../state/entities"; -import { useEnvironmentQuery } from "../../state/query"; +import { useThreadShell } from "../../state/entities"; import type { GitActionProgress } from "../../state/use-vcs-action-state"; -import { vcsEnvironment } from "../../state/vcs"; import { GitActionProgressOverlay } from "../threads/GitActionProgressOverlay"; import { dispatchHardwareKeyboardCommand, @@ -40,43 +38,16 @@ export function HardwareKeyboardCommandProvider({ const navigation = useNavigation(); const activeThreadRef = useMemo(() => parseActiveThreadPath(pathname), [pathname]); const activeThread = useThreadShell(activeThreadRef); - const activeProjectRef = useMemo( - () => - activeThread === null - ? null - : { - environmentId: activeThread.environmentId, - projectId: activeThread.projectId, - }, - [activeThread], - ); - const activeProject = useProject(activeProjectRef); - const activeThreadCwd = activeThread?.worktreePath ?? activeProject?.workspaceRoot ?? null; - const gitStatus = useEnvironmentQuery( - activeThread !== null && - activeThread.linkedPullRequest == null && - activeThread.branch !== null && - activeThreadCwd !== null - ? vcsEnvironment.status({ - environmentId: activeThread.environmentId, - input: { cwd: activeThreadCwd }, - }) - : null, - ).data; - const detectedPullRequestUrl = - activeThread?.branch != null && gitStatus?.refName === activeThread.branch - ? (gitStatus.pr?.url ?? null) - : null; const copyTarget = useMemo( () => activeThreadRef === null ? null : resolveThreadReferenceCopyTarget({ threadId: activeThread?.id ?? activeThreadRef.threadId, - linkedPullRequestUrl: activeThread?.linkedPullRequest?.url ?? null, - detectedPullRequestUrl, + linkedPullRequestUrl: + (activeThread?.linkedPullRequest ?? activeThread?.branchPullRequest)?.url ?? null, }), - [activeThread, activeThreadRef, detectedPullRequestUrl], + [activeThread, activeThreadRef], ); const [copyFeedback, setCopyFeedback] = useState(EMPTY_COPY_FEEDBACK); const copyRequestIdRef = useRef(0); diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 07357a1b7524..f529432070bf 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -353,13 +353,6 @@ function ThreadNavigationSidebarPane( }), [threadListV2Enabled, groups, groupDisplayStates, hasSearchQuery], ); - const projectCwdByKey = useMemo(() => { - const map = new Map(); - for (const project of projects) { - map.set(scopedProjectKey(project.environmentId, project.id), project.workspaceRoot); - } - return map; - }, [projects]); const projectByKey = useMemo(() => { const map = new Map(); for (const project of projects) { @@ -742,7 +735,6 @@ function ThreadNavigationSidebarPane( () => ({ selectedThreadKey: props.selectedThreadKey ?? "", projectByKey, - projectCwdByKey, projectTitleByProjectKey, savedConnectionsById, serverConfigs, @@ -752,7 +744,6 @@ function ThreadNavigationSidebarPane( [ props.selectedThreadKey, projectByKey, - projectCwdByKey, projectTitleByProjectKey, savedConnectionsById, serverConfigs, @@ -914,7 +905,6 @@ function ThreadNavigationSidebarPane( onPinThread={pinThread} onUnpinThread={unpinThread} onMovePinnedThread={movePinnedThread} - projectCwd={projectCwdByKey.get(scopeKey) ?? null} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} @@ -1000,10 +990,6 @@ function ThreadNavigationSidebarPane( savedConnectionsById[thread.environmentId]?.environmentLabel ?? null } environmentMachine={machineByEnvironmentId.get(thread.environmentId)} - projectCwd={ - projectCwdByKey.get(scopedProjectKey(thread.environmentId, thread.projectId)) ?? - null - } isLast={item.isLast} searchMatch={threadSearchMatchByKey.get( threadSearchMatchKey({ @@ -1054,7 +1040,6 @@ function ThreadNavigationSidebarPane( pinThread, pinningEnvironmentIds, projectByKey, - projectCwdByKey, projectTitleByProjectKey, regenerateThreadTitle, props.onNewThreadInProject, diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index fc3898279e3e..e65cf7a9fb43 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -416,7 +416,6 @@ export const ThreadListRow = memo(function ThreadListRow(props: { readonly thread: EnvironmentThreadShell; readonly environmentLabel: string | null; readonly environmentMachine?: EnvironmentMachineKind; - readonly projectCwd: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly isLast: boolean; @@ -453,7 +452,7 @@ export const ThreadListRow = memo(function ThreadListRow(props: { const { thread, onSelectThread, onArchiveThread, onDeleteThread, onRegenerateThreadTitle } = props; const status = resolveThreadStatus(thread); - const pr = useThreadPr(thread, props.projectCwd); + const pr = useThreadPr(thread); const timestamp = relativeTime( thread.latestUserMessageAt ?? thread.updatedAt ?? thread.createdAt, ); 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 e34dce059014..e66fa778476b 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -377,7 +377,6 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly canMovePinnedDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; - readonly projectCwd?: string | null; readonly searchMatch?: EnvironmentThreadSearchMatch; readonly searchQuery?: string; readonly simultaneousSwipeGesture?: ComponentProps< @@ -403,7 +402,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; - const pr = useThreadPr(thread, props.projectCwd ?? props.project?.workspaceRoot ?? null); + const pr = useThreadPr(thread); const theme = useUniwindTheme(); const screenColor = theme["--color-screen"]; diff --git a/apps/mobile/src/state/use-thread-pr.ts b/apps/mobile/src/state/use-thread-pr.ts index 8e4eb27963ac..e824ce56217b 100644 --- a/apps/mobile/src/state/use-thread-pr.ts +++ b/apps/mobile/src/state/use-thread-pr.ts @@ -12,9 +12,8 @@ import { connectionAtomRuntime } from "../connection/runtime"; import { appAtomRegistry } from "./atom-registry"; import { useEnvironmentQuery } from "./query"; import { presentThreadPr, type ThreadPrPresentation } from "./thread-pr-presentation"; -import { vcsEnvironment } from "./vcs"; -const linkedPullRequestDetailAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); +const pullRequestSummaryAtom = createLinkedPullRequestSummaryAtomFamily(connectionAtomRuntime); const MAX_THREAD_PR_SNAPSHOTS = 500; interface ThreadPrSnapshot { @@ -23,7 +22,7 @@ interface ThreadPrSnapshot { } // One bounded cache survives row virtualization without retaining one live -// atom for every thread, branch, directory, or linked pull request ever seen. +// atom for every thread or pull request ever seen. const threadPrSnapshotsAtom = Atom.make>(new Map()).pipe( Atom.keepAlive, Atom.withLabel("mobile:thread-pr-snapshots"), @@ -36,20 +35,13 @@ export { } from "./thread-pr-presentation"; /** - * Live PR status for a thread's branch. Subscriptions are deduplicated per - * (environmentId, cwd) by the atom family, so many rows on the same worktree - * or project root share one stream — and virtualization means only visible - * rows subscribe at all. + * Live status for a thread's server-provided PR. Visible rows share a summary + * request for the same PR in the same environment. */ -export function useThreadPr( - thread: EnvironmentThreadShell, - projectCwd: string | null, -): ThreadPrPresentation | null { - const cwd = thread.worktreePath ?? projectCwd; +export function useThreadPr(thread: EnvironmentThreadShell): ThreadPrPresentation | null { + const pullRequestRef = thread.linkedPullRequest ?? thread.branchPullRequest ?? null; const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const snapshotIdentity = JSON.stringify( - thread.linkedPullRequest ?? { branch: thread.branch, cwd }, - ); + const snapshotIdentity = JSON.stringify(pullRequestRef); // Select this row's entry so writes for other rows do not re-render it. const snapshotEntry = useAtomValue( threadPrSnapshotsAtom, @@ -59,45 +51,30 @@ export function useThreadPr( ), ); const snapshot = snapshotEntry?.identity === snapshotIdentity ? snapshotEntry.presentation : null; - const gitStatus = useEnvironmentQuery( - thread.linkedPullRequest == null && thread.branch !== null && cwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd }, - }) - : null, - ); - const linkedPullRequest = useEnvironmentQuery( - thread.linkedPullRequest == null + const pullRequestSummary = useEnvironmentQuery( + pullRequestRef === null ? null - : linkedPullRequestDetailAtom({ + : pullRequestSummaryAtom({ environmentId: thread.environmentId, input: { - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, + projectId: pullRequestRef.projectId, + repository: pullRequestRef.repository, + number: pullRequestRef.number, }, }), ); const live = useMemo(() => { - if (thread.linkedPullRequest != null) { - const detail = linkedPullRequest.data; - return detail === null - ? undefined - : presentThreadPr(pullRequestDetailToVcsStatus(detail), { - kind: detail.provider, - name: detail.provider, - baseUrl: "", - }); - } - - const status = gitStatus.data; - if (thread.branch === null) return null; - if (status === null) return undefined; - if (status.refName !== thread.branch || !status.pr) return null; - return presentThreadPr(status.pr, status.sourceControlProvider); - }, [gitStatus.data, linkedPullRequest.data, thread.branch, thread.linkedPullRequest]); + if (pullRequestRef === null) return null; + const summary = pullRequestSummary.data; + return summary === null + ? undefined + : presentThreadPr(pullRequestDetailToVcsStatus(summary), { + kind: summary.provider, + name: summary.provider, + baseUrl: "", + }); + }, [pullRequestRef, pullRequestSummary.data]); useEffect(() => { if (live === undefined) return; diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index e0e87d609d5f..b7350dd5dddf 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -55,6 +55,7 @@ function threadDetailToShell( branch: thread.branch, worktreePath: thread.worktreePath, linkedPullRequest: thread.linkedPullRequest ?? null, + branchPullRequest: thread.branchPullRequest ?? null, latestTurn: thread.latestTurn, createdAt: thread.createdAt, updatedAt: thread.updatedAt, diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index ce34855a3194..1c035d5fa15a 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -67,6 +67,7 @@ import { } from "../src/orchestration/Services/OrchestrationEngine.ts"; import { ThreadDeletionReactor } from "../src/orchestration/Services/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "../src/orchestration/ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "../src/orchestration/ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../src/orchestration/Services/OrchestrationReactor.ts"; import { ProjectionSnapshotQuery } from "../src/orchestration/Services/ProjectionSnapshotQuery.ts"; import { @@ -394,6 +395,12 @@ export const makeOrchestrationIntegrationHarness = ( drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { + start: () => Effect.void, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { start: () => Effect.void, diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index b8f4090453be..999a0c202216 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -13,6 +13,7 @@ import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as PlatformError from "effect/PlatformError"; import * as References from "effect/References"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import { ChildProcessSpawner } from "effect/unstable/process"; import { expect } from "vite-plus/test"; @@ -30,10 +31,13 @@ import { TextGenerationError, } from "@t3tools/contracts"; import * as GitHubCli from "../sourceControl/GitHubCli.ts"; +import * as GitLabCli from "../sourceControl/GitLabCli.ts"; import * as TextGeneration from "../textGeneration/TextGeneration.ts"; import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts"; +import * as GitLabSourceControlProvider from "../sourceControl/GitLabSourceControlProvider.ts"; +import type { SourceControlProvider } from "../sourceControl/SourceControlProvider.ts"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectSetupScriptRunner from "../project/ProjectSetupScriptRunner.ts"; @@ -41,6 +45,8 @@ import * as ProviderRegistry from "../provider/Services/ProviderRegistry.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as GitManager from "./GitManager.ts"; +const encodeCliJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + interface FakeGhScenario { prListSequence?: string[]; prListByHeadSelector?: Record; @@ -620,6 +626,7 @@ function preparePullRequestThread( function makeManager(input?: { ghScenario?: FakeGhScenario; + sourceControlProvider?: SourceControlProvider["Service"]; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; @@ -659,7 +666,10 @@ function makeManager(input?: { ); const sourceControlRegistryLayer = Layer.effect( SourceControlProviderRegistry.SourceControlProviderRegistry, - GitHubSourceControlProvider.make.pipe( + (input?.sourceControlProvider === undefined + ? GitHubSourceControlProvider.make + : Effect.succeed(input.sourceControlProvider) + ).pipe( Effect.map((provider) => SourceControlProviderRegistry.SourceControlProviderRegistry.of({ get: () => Effect.succeed(provider), @@ -1146,7 +1156,12 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/saved-branch", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ + number: 216, + title: "Saved branch PR", + url: "https://github.com/pingdotgg/t3code/pull/216", + baseRef: "main", + headRef: "feature/saved-branch", state: "open", closedAt: null, mergedAt: null, @@ -1191,7 +1206,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { const pullRequest = yield* manager.branchPullRequest({ cwd: repoDir, branch: "main" }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", closedAt: null, mergedAt: "2026-04-07T15:00:00Z", @@ -1244,7 +1259,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/deleted-local-branch", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", closedAt: null, mergedAt: null, @@ -1310,7 +1325,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/deleted-fork-branch", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", closedAt: null, mergedAt: null, @@ -1432,6 +1447,17 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { updatedAt: "2026-04-07T15:00:00Z", }, ]), + encodeCliJson([ + { + number: 221, + title: "New PR on the same branch", + url: "https://github.com/pingdotgg/codething-mvp/pull/221", + baseRefName: "main", + headRefName: "feature/shared-pr-cache", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + }, + ]), ], }, }); @@ -1445,6 +1471,16 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(status.pr?.state).toBe("merged"); expect(pullRequest?.state).toBe("merged"); expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); + const refreshed = yield* manager.branchPullRequest( + { cwd: repoDir, branch: "feature/shared-pr-cache" }, + { refresh: true }, + ); + expect(refreshed).toMatchObject({ + number: 221, + state: "open", + repositoryKey: "github.com/pingdotgg/codething-mvp", + }); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(2); }), ); @@ -1459,7 +1495,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { yield* runGit(repoDir, ["push", "-u", "origin", "feature/lookup-failure"]); yield* runGit(repoDir, ["checkout", "main"]); - const { manager } = yield* makeManager({ + const { manager, ghCalls } = yield* makeManager({ ghScenario: { failWith: new GitHubCli.GitHubCliUnavailableError({ command: "gh", @@ -1474,6 +1510,11 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { .pipe(Effect.flip); expect(error._tag).toBe("SourceControlProviderError"); + const refreshError = yield* manager + .branchPullRequest({ cwd: repoDir, branch: "feature/lookup-failure" }, { refresh: true }) + .pipe(Effect.flip); + expect(refreshError._tag).toBe("SourceControlProviderError"); + expect(ghCalls.filter((call) => call.startsWith("pr list "))).toHaveLength(1); }), ); @@ -1594,6 +1635,186 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { expect(Duration.toMillis(GitManager.prLookupFailureTtl(20))).toBe(900_000); }); + it.each([ + [ + "https://github.example.com/team/repository/pull/42?tab=files", + "github.example.com/team/repository", + ], + [ + "https://gitlab.example.com/group/subgroup/repository/-/merge_requests/42", + "gitlab.example.com/group/subgroup/repository", + ], + ["https://bitbucket.org/team/repository/pull-requests/42", "bitbucket.org/team/repository"], + [ + "https://dev.azure.com/org/project/_git/repository/pullrequest/42", + "dev.azure.com/org/project/_git/repository", + ], + [ + "https://org.visualstudio.com/project/_git/repository/pullrequest/42", + "org.visualstudio.com/project/_git/repository", + ], + [ + "https://gitlab.example/group/pull/123/repository/-/merge_requests/42", + "gitlab.example/group/pull/123/repository", + ], + ["https://github.example.com/team/repository/issues/42", null], + ] as const)("reads the repository from the returned PR URL %s", (url, expected) => { + expect(GitManager.pullRequestRepositoryKey(url)).toBe(expected); + }); + + it.effect("distinguishes Enterprise forks with the same head branch", () => + 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* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", "feature"]); + yield* runGit(repoDir, ["push", "-u", "fork", "feature"]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@github.example.com:team/repository.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "fork", + "git@github.example.com:alice/repository.git", + forkDir, + ); + const output = encodeCliJson([ + { + number: 2, + title: "Another fork", + url: "https://github.example.com/team/repository/pull/2", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + updatedAt: "2026-04-08T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "bob/repository" }, + headRepositoryOwner: { login: "bob" }, + }, + { + number: 1, + title: "This fork", + url: "https://github.example.com/team/repository/pull/1", + baseRefName: "main", + headRefName: "feature", + state: "OPEN", + updatedAt: "2026-04-07T15:00:00Z", + isCrossRepository: true, + headRepository: { nameWithOwner: "alice/repository" }, + headRepositoryOwner: { login: "alice" }, + }, + ]); + const { manager } = yield* makeManager({ + ghScenario: { + prListByHeadSelector: { + "alice:feature": output, + "fork:feature": output, + feature: output, + }, + }, + }); + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch: "feature" })).toMatchObject({ + number: 1, + repositoryKey: "github.example.com/team/repository", + }); + }), + ); + + it.effect.each([ + "git@gitlab.com:Group/Subgroup/Fork.git", + "https://gitlab.com/Group/Subgroup/Fork.git", + ])("matches nested GitLab forks through the adapter for %s", (remoteUrl) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const forkDir = yield* createBareRemote(); + const branch = "feature/NestedGroups"; + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "-u", "origin", "main"]); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["checkout", "-b", branch]); + yield* runGit(repoDir, ["push", "-u", "fork", branch]); + yield* configureVisibleRemoteUrlWithLocalRewrite( + repoDir, + "origin", + "git@gitlab.com:Group/Upstream/Repository.git", + originDir, + ); + yield* configureVisibleRemoteUrlWithLocalRewrite(repoDir, "fork", remoteUrl, forkDir); + const output = encodeCliJson([ + { + iid: 2, + title: "Another subgroup's fork", + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/2", + target_branch: "main", + source_branch: branch, + state: "opened", + updated_at: "2026-04-08T15:00:00Z", + source_project_id: 102, + target_project_id: 100, + source_project: { path_with_namespace: "Group/Other/Fork" }, + }, + { + iid: 1, + title: "This subgroup's fork", + web_url: "https://gitlab.com/Group/Upstream/Repository/-/merge_requests/1", + target_branch: "main", + source_branch: branch, + state: "opened", + updated_at: "2026-04-07T15:00:00Z", + source_project_id: 101, + target_project_id: 100, + source_project: { path_with_namespace: "Group/Subgroup/Fork" }, + }, + ]); + const calls: VcsProcess.VcsProcessInput[] = []; + const provider = yield* GitLabSourceControlProvider.make.pipe( + Effect.provide( + GitLabCli.layer.pipe( + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.sync(() => { + calls.push(input); + return fakeGhOutput(output); + }), + }), + ), + ), + ), + ); + const { manager } = yield* makeManager({ sourceControlProvider: provider }); + + expect(yield* manager.branchPullRequest({ cwd: repoDir, branch })).toMatchObject({ + number: 1, + repositoryKey: "gitlab.com/group/upstream/repository", + }); + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.command).toBe("glab"); + expect(call.args).toEqual([ + "mr", + "list", + "--source-branch", + branch, + "--all", + "--per-page", + "20", + "--output", + "json", + ]); + } + }), + ); + it.effect( "status ignores unrelated fork PRs when the current branch tracks the same repository", () => @@ -2149,7 +2370,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { branch: "feature/fork-settle", }); - expect(pullRequest).toEqual({ + expect(pullRequest).toMatchObject({ state: "merged", closedAt: null, mergedAt: null, diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 76f2ebc6b510..f60eb2781872 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -77,6 +77,13 @@ export interface GitRemoteStatusOptions extends GitVcsDriver.GitRemoteStatusOpti readonly refreshMissingPullRequest?: boolean; } +export type GitBranchPullRequest = NonNullable & { + readonly repositoryKey: string | null; + readonly updatedAt: string | null; + readonly closedAt?: string | null; + readonly mergedAt?: string | null; +}; + interface SourceControlTextGenerationSettings { readonly modelSelection: ModelSelection; readonly style: SourceControlWritingStyleSettings; @@ -96,18 +103,10 @@ export class GitManager extends Context.Service< options?: 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; - readonly closedAt?: string | null; - readonly mergedAt?: string | null; - } | null, - GitManagerServiceError - >; + readonly branchPullRequest: ( + input: { readonly cwd: string; readonly branch: string }, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; readonly invalidateLocalStatus: (cwd: string) => Effect.Effect; readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; @@ -214,9 +213,26 @@ interface BranchHeadContext { isCrossRepository: boolean; } +export function pullRequestRepositoryKey(value: string): string | null { + try { + const url = new URL(value); + const match = + /^(.*)(?:\/pull\/|\/-\/merge_requests\/|\/pull-requests\/|\/pullrequest\/)\d+(?:\/.*)?$/iu.exec( + url.pathname, + ); + if (match?.[1] === undefined) return null; + url.pathname = match[1]; + url.search = ""; + url.hash = ""; + return normalizeGitRemoteUrl(url.toString()); + } catch { + return null; + } +} + function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); - const match = /^https:\/\/github\.com\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); + const match = /^https?:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); const repositoryName = match?.[1]?.trim() ?? ""; return repositoryName.length > 0 ? repositoryName : null; } @@ -254,14 +270,14 @@ function resolvePullRequestWorktreeLocalBranchName( return `t3code/pr-${pullRequest.number}/${suffix}`; } -function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { +function parseRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = - /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( + /^(?:[^@/\s]+@[^:/\s]+:|(?:ssh|https?|git):\/\/[^/]+\/)((?:[^/\s]+\/)+[^/\s]+?)(?:\.git)?\/?$/iu.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; @@ -273,6 +289,7 @@ function parseRepositoryOwnerLogin(nameWithOwner: string | null): string | null if (trimmed.length === 0) { return null; } + // GitLab reports the top-level group as owner. The full path distinguishes subgroups. const [ownerLogin] = trimmed.split("/"); const normalizedOwnerLogin = ownerLogin?.trim() ?? ""; return normalizedOwnerLogin.length > 0 ? normalizedOwnerLogin : null; @@ -1261,7 +1278,7 @@ export const make = Effect.gen(function* () { } const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`); - const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); + const repositoryNameWithOwner = parseRepositoryNameWithOwnerFromRemoteUrl(remoteUrl); return { remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null, repositoryNameWithOwner, @@ -2031,7 +2048,7 @@ export const make = Effect.gen(function* () { }); const branchPullRequest: GitManager["Service"]["branchPullRequest"] = Effect.fn( "branchPullRequest", - )(function* ({ cwd, branch }) { + )(function* ({ cwd, branch }, options) { const cacheCwd = yield* normalizeStatusCacheKey(cwd); const remotes = yield* gitCore.execute({ operation: "GitManager.branchPullRequest.remotes", @@ -2111,6 +2128,14 @@ export const make = Effect.gen(function* () { localBranchExists, ...(localBranchExists ? {} : { remoteName }), }); + if (options?.refresh) { + // A completed turn can create a PR or reuse a merged PR's branch. + // Refresh successful answers, but keep failed lookups' retry backoff. + const cached = yield* Cache.getOption(prLookupCache, cacheKey).pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(cached)) yield* Cache.invalidate(prLookupCache, cacheKey); + } let cached = yield* Cache.get(prLookupCache, cacheKey); // The cached head context may have resolved on a different remote than // the saved upstream: a branch tracking origin/main but pushed to a fork @@ -2165,12 +2190,13 @@ export const make = Effect.gen(function* () { ) { return null; } - const statusPr = toStatusPr(latest); return { - state: statusPr.state, - updatedAt: statusPr.updatedAt, + ...toStatusPr(latest), closedAt: latest.closedAt ?? null, mergedAt: latest.mergedAt ?? null, + // Hosting CLIs can select an upstream repository instead of origin. + // The returned PR URL names the repository that actually owns it. + repositoryKey: pullRequestRepositoryKey(latest.url), }; }); const invalidateLocalStatus: GitManager["Service"]["invalidateLocalStatus"] = Effect.fn( diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2c426c5ee5d0..0b2e08c40d68 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -13,6 +13,7 @@ import { ProjectId, ThreadId, TurnId, + type OrchestrationCommand, type OrchestrationEvent, ProviderInstanceId, } from "@t3tools/contracts"; @@ -993,6 +994,216 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it.each(["unlink", "relink", "branch", "worktree", "project", "delete"] as const)( + "rejects PR discovery completed after a newer %s command", + async (change) => { + const system = await createOrchestrationSystem(); + try { + const projectId = ProjectId.make("pr-race-project"); + const threadId = ThreadId.make("pr-race-thread"); + const previous = { + projectId, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const replacement = { + ...previous, + number: 2, + url: "https://example.test/owner/repository/pull/2", + }; + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("pr-race-project-create"), + projectId, + title: "PR race project", + workspaceRoot: "/tmp/pr-race-project", + defaultModelSelection: null, + createdAt: now(), + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("pr-race-thread-create"), + threadId, + projectId, + title: "PR race thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + createdAt: now(), + }), + ); + const observed = await system.run( + system.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("pr-race-link"), + threadId, + linkedPullRequest: previous, + }), + ); + const metadataChanges = { + unlink: { linkedPullRequest: null }, + relink: { + linkedPullRequest: { + ...previous, + number: 3, + url: "https://example.test/owner/repository/pull/3", + }, + }, + branch: { branch: "another-feature" }, + worktree: { worktreePath: "/tmp/another-worktree" }, + project: {}, + }; + await system.run( + system.engine.dispatch( + change === "project" + ? { + type: "project.meta.update", + commandId: CommandId.make("pr-race-project-move"), + projectId, + workspaceRoot: "/tmp/another-project-root", + } + : change === "delete" + ? { type: "thread.delete", commandId: CommandId.make("pr-race-delete"), threadId } + : { + type: "thread.meta.update", + commandId: CommandId.make(`pr-race-${change}`), + threadId, + ...metadataChanges[change], + }, + ), + ); + const command = { + type: "thread.pull-request.sync", + commandId: CommandId.make("pr-race-stale-sync"), + threadId, + projectId, + snapshotSequence: observed.sequence, + expected: { + workspaceRoot: "/tmp/pr-race-project", + branch: "feature", + worktreePath: null, + linkedPullRequest: previous, + branchPullRequest: null, + }, + branchPullRequest: replacement, + linkedPullRequest: replacement, + } satisfies OrchestrationCommand; + const error = await system.run(system.engine.dispatch(command).pipe(Effect.flip)); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + if (change === "delete") return; + const current = (await system.readModel()).threads[0]; + expect(current?.branchPullRequest ?? null).toBeNull(); + expect(current?.linkedPullRequest ?? null).toEqual( + change === "unlink" + ? null + : change === "relink" + ? metadataChanges.relink.linkedPullRequest + : previous, + ); + } finally { + await system.dispose(); + } + }, + ); + + it("saves PR associations through streaming and unrelated metadata edits", async () => { + const system = await createOrchestrationSystem(); + try { + const projectId = ProjectId.make("pr-sync-project"); + const threadId = ThreadId.make("pr-sync-thread"); + await system.run( + system.engine.dispatch({ + type: "project.create", + commandId: CommandId.make("pr-sync-project-create"), + projectId, + title: "PR sync project", + workspaceRoot: "/tmp/pr-sync-project", + defaultModelSelection: null, + createdAt: now(), + }), + ); + const created = await system.run( + system.engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("pr-sync-thread-create"), + threadId, + projectId, + title: "PR sync thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + createdAt: now(), + }), + ); + const reference = { + projectId, + repository: "owner/repository", + number: 42, + url: "https://example.test/owner/repository/pull/42", + }; + const activityAt = "2026-01-01T01:00:00.000Z"; + await system.run( + system.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.make("pr-sync-streaming-message"), + threadId, + messageId: MessageId.make("pr-sync-message"), + delta: "The PR is ready.", + createdAt: activityAt, + }), + ); + await system.run( + system.engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("pr-sync-title-and-model"), + threadId, + title: "Renamed thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + }), + ); + await system.run( + system.engine.dispatch({ + type: "project.meta.update", + commandId: CommandId.make("pr-sync-project-title"), + projectId, + title: "Renamed project", + }), + ); + const beforeSync = (await system.readModel()).threads[0]; + await system.run( + system.engine.dispatch({ + type: "thread.pull-request.sync", + commandId: CommandId.make("pr-sync-discovery"), + projectId, + threadId, + snapshotSequence: created.sequence, + expected: { + workspaceRoot: "/tmp/pr-sync-project", + branch: "feature", + worktreePath: null, + linkedPullRequest: null, + branchPullRequest: null, + }, + branchPullRequest: reference, + }), + ); + const current = (await system.readModel()).threads[0]; + expect(current?.branchPullRequest).toEqual(reference); + expect(current?.linkedPullRequest ?? null).toBeNull(); + expect(current?.updatedAt).toBe(beforeSync?.updatedAt); + } finally { + await system.dispose(); + } + }); + it("allows authoritative worktree bootstrap to assign a temporary branch", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 8512007ae8d3..4350d145810c 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -185,6 +185,23 @@ const makeOrchestrationEngine = Effect.gen(function* () { }); } + // The decider compares the lookup inputs. Only recreation needs an + // event check, since it can reset a thread to the same field values. + if ( + envelope.command.type === "thread.pull-request.sync" && + (yield* eventStore.hasEventAfter({ + aggregateKind: "thread", + aggregateId: envelope.command.threadId, + sequenceExclusive: envelope.command.snapshotSequence, + type: "thread.created", + })) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: envelope.command.type, + detail: `thread ${envelope.command.threadId} was recreated before pull request discovery`, + }); + } + if ( envelope.command.type === "thread.auto-settle" && threadBackgroundLiveness.getThreadBackgroundLiveness(envelope.command.threadId) !== null diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 1340480bce55..dd76721defa0 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -10,6 +10,7 @@ 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 ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; @@ -65,6 +66,15 @@ describe("OrchestrationReactor", () => { drainThrough: () => Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(ThreadPullRequestReactor.ThreadPullRequestReactor, { + start: () => { + started.push("thread-pull-request-reactor"); + return Effect.void; + }, + drain: Effect.void, + }), + ), Layer.provideMerge( Layer.succeed(ThreadSettlementReactor.ThreadSettlementReactor, { start: () => { @@ -95,6 +105,7 @@ describe("OrchestrationReactor", () => { "provider-command-reactor", "checkpoint-reactor", "thread-deletion-reactor", + "thread-pull-request-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 649e803809db..a86907b0d78b 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -10,6 +10,7 @@ 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 ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { @@ -18,6 +19,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { const checkpointReactor = yield* CheckpointReactor; const threadDeletionReactor = yield* ThreadDeletionReactor; const threadSettlementReactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay; const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () { @@ -25,6 +27,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () { yield* providerCommandReactor.start(); yield* checkpointReactor.start(); yield* threadDeletionReactor.start(); + yield* threadPullRequestReactor.start(); yield* threadSettlementReactor.start(); yield* agentAwarenessRelay.start(); }); diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index eb67d8a5b85d..f619f6a93916 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -8,6 +8,7 @@ import { MessageId, ProjectId, ThreadId, + ThreadLinkedPullRequest, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -18,6 +19,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 Schema from "effect/Schema"; import * as SqlClient from "effect/unstable/sql/SqlClient"; import { makeSqlStatementCounter } from "../../../integration/SqlStatementCounter.integration.ts"; @@ -59,6 +61,9 @@ const exists = (filePath: string) => }); const BaseTestLayer = makeProjectionPipelinePrefixedTestLayer("t3-projection-pipeline-test-"); +const encodeThreadLinkedPullRequest = Schema.encodeSync( + Schema.fromJsonString(ThreadLinkedPullRequest), +); it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-cursor-batch-")))( "OrchestrationProjectionPipeline cursor batches", @@ -204,6 +209,93 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-import-shell-") }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-branch-pr-projection-")))( + "branch pull request projection", + (it) => { + it.effect("persists branch pull request updates without changing manual links", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-01-01T00:00:00.000Z"; + const threadId = ThreadId.make("thread-pull-request"); + const projectId = ProjectId.make("project-pull-request"); + const eventFields = { + aggregateKind: "thread" as const, + aggregateId: threadId, + occurredAt: now, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + }; + const created = yield* eventStore.append({ + ...eventFields, + type: "thread.created", + eventId: EventId.make("evt-pull-request-created"), + payload: { + threadId, + projectId, + title: "Pull request thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* projectionPipeline.projectEvent(created); + const linkedPullRequest = { + projectId, + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const branchPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const updates = [ + { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, + { payload: { title: "Renamed thread" }, expected: branchPullRequest }, + { payload: { branchPullRequest: null }, expected: null }, + ]; + + for (const [index, update] of updates.entries()) { + const event = yield* eventStore.append({ + ...eventFields, + type: "thread.meta-updated", + eventId: EventId.make(`evt-pull-request-update-${index}`), + payload: { threadId, updatedAt: now, ...update.payload }, + }); + yield* projectionPipeline.projectEvent(event); + + const rows = yield* sql<{ + readonly linkedPullRequest: string | null; + readonly branchPullRequest: string | null; + }>` + SELECT + linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(rows, [ + { + linkedPullRequest: encodeThreadLinkedPullRequest(linkedPullRequest), + branchPullRequest: + update.expected === null ? null : encodeThreadLinkedPullRequest(update.expected), + }, + ]); + } + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect("bootstraps all projection states and writes projection rows", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7c71d7b6ee60..bcef68170a49 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -605,6 +605,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti branch: event.payload.branch, worktreePath: event.payload.worktreePath, linkedPullRequest: null, + branchPullRequest: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -805,6 +806,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ...(event.payload.linkedPullRequest !== undefined ? { linkedPullRequest: event.payload.linkedPullRequest } : {}), + ...(event.payload.branchPullRequest !== undefined + ? { branchPullRequest: event.payload.branchPullRequest } + : {}), updatedAt: event.payload.updatedAt, }); return; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 2e0a3c3459a5..08a07c0be75b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -6,6 +6,7 @@ import { MessageId, ProjectId, ThreadId, + ThreadLinkedPullRequest, TurnId, ProviderInstanceId, } from "@t3tools/contracts"; @@ -36,6 +37,9 @@ const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(val const encodeChatAttachments = Schema.encodeEffect( Schema.fromJsonString(Schema.Array(ChatAttachment)), ); +const encodeThreadLinkedPullRequest = Schema.encodeSync( + Schema.fromJsonString(ThreadLinkedPullRequest), +); const projectionSnapshotLayer = it.layer( OrchestrationProjectionSnapshotQueryLive.pipe( @@ -52,6 +56,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; + const branchPullRequest = { + projectId: asProjectId("project-1"), + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; @@ -92,6 +102,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { branch, worktree_path, linked_pull_request_json, + branch_pull_request_json, latest_turn_id, latest_user_message_at, pending_approval_count, @@ -113,6 +124,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { NULL, NULL, '{"projectId":"project-1","repository":"pingdotgg/t3code","number":42,"url":"https://github.com/pingdotgg/t3code/pull/42"}', + ${encodeThreadLinkedPullRequest(branchPullRequest)}, 'turn-1', '2026-02-24T00:00:04.000Z', 1, @@ -323,6 +335,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }, + branchPullRequest, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -451,6 +464,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }, + branchPullRequest, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -498,6 +512,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); + const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); + assert.equal(threadShell._tag, "Some"); + if (threadShell._tag === "Some") { + assert.deepEqual(threadShell.value.branchPullRequest, branchPullRequest); + } + yield* sql` INSERT INTO projection_thread_activities ( activity_id, @@ -743,6 +765,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { Effect.gen(function* () { const snapshotQuery = yield* ProjectionSnapshotQuery; const sql = yield* SqlClient.SqlClient; + const branchPullRequest = { + projectId: asProjectId("project-archive-test"), + repository: "pingdotgg/t3code", + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_threads`; @@ -849,6 +877,13 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { shellSnapshot.threads.map((thread) => thread.id), [ThreadId.make("thread-active")], ); + assert.equal(shellSnapshot.threads[0]?.branchPullRequest, null); + + yield* sql` + UPDATE projection_threads + SET branch_pull_request_json = ${encodeThreadLinkedPullRequest(branchPullRequest)} + WHERE thread_id = 'thread-archived' + `; const archivedShellSnapshot = yield* snapshotQuery.getArchivedShellSnapshot(); assert.deepEqual( @@ -856,6 +891,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { [ThreadId.make("thread-archived")], ); assert.equal(archivedShellSnapshot.threads[0]?.archivedAt, "2026-04-06T00:00:06.000Z"); + assert.deepEqual(archivedShellSnapshot.threads[0]?.branchPullRequest, branchPullRequest); const activeContext = yield* snapshotQuery.getThreadRuntimeContext( ThreadId.make("thread-active"), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index d9f4526e9e63..b15b72ee5673 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -116,6 +116,7 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -500,6 +501,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -538,6 +540,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -578,6 +581,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1067,6 +1071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -2062,6 +2067,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2275,6 +2281,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2415,6 +2422,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2563,6 +2571,7 @@ pending_approval_requests AS ( interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + branchPullRequest: row.branchPullRequest, ...(row.linkedPullRequest === null ? {} : { linkedPullRequest: row.linkedPullRequest }), @@ -2884,6 +2893,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + branchPullRequest: threadRow.value.branchPullRequest, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), @@ -3165,6 +3175,7 @@ pending_approval_requests AS ( interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + branchPullRequest: threadRow.value.branchPullRequest, ...(threadRow.value.linkedPullRequest === null ? {} : { linkedPullRequest: threadRow.value.linkedPullRequest }), diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts new file mode 100644 index 000000000000..f9872e6dd22a --- /dev/null +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.test.ts @@ -0,0 +1,613 @@ +import { + EventId, + GitManagerError, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationEvent, + type OrchestrationProjectShell, + type OrchestrationShellSnapshot, + type OrchestrationThreadShell, + type PullRequestRef, + type PullRequestSummary, + type ThreadLinkedPullRequest, +} from "@t3tools/contracts"; +import { describe, 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"; +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, type GitBranchPullRequest } from "../git/GitManager.ts"; +import { PullRequestService } from "../pullRequest/PullRequestService.ts"; +import { RepositoryIdentityResolver } from "../project/RepositoryIdentityResolver.ts"; +import { ServerActivation } from "../serverActivation.ts"; +import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; +import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +import * as ThreadPullRequestReactor from "./ThreadPullRequestReactor.ts"; + +const NOW = "2026-09-01T12:00:00.000Z"; +const PROJECT_ID = ProjectId.make("project"); +const REPOSITORY = "owner/repository"; +const REPOSITORY_KEY = `github.com/${REPOSITORY}`; +type SyncCommand = Extract; + +function reference(number: number): ThreadLinkedPullRequest { + return { + projectId: PROJECT_ID, + repository: REPOSITORY, + number, + url: `https://github.com/${REPOSITORY}/pull/${number}`, + }; +} + +function branchPullRequest( + number = 42, + state: GitBranchPullRequest["state"] = "open", +): GitBranchPullRequest { + return { + ...reference(number), + title: "Branch pull request", + baseRef: "main", + headRef: "feature", + state, + updatedAt: NOW, + repositoryKey: REPOSITORY_KEY, + }; +} + +function summary(input: PullRequestRef, state: PullRequestSummary["state"]): PullRequestSummary { + return { + ...input, + provider: "github", + title: "Pull request", + url: reference(input.number).url, + state, + headBranch: "feature", + baseBranch: "main", + updatedAt: NOW, + }; +} + +function thread( + 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: "feature", + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: NOW, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, + }; +} + +const project = { + id: PROJECT_ID, + title: "Project", + workspaceRoot: "/workspace/project", + repositoryIdentity: { + canonicalKey: REPOSITORY_KEY, + displayName: REPOSITORY, + rootPath: "/workspace/project", + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: `git@github.com:${REPOSITORY}.git`, + }, + }, + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, +} satisfies OrchestrationProjectShell; + +const makeHarness = Effect.fn("makeThreadPullRequestHarness")(function* (options: { + readonly threads: ReadonlyArray; + readonly branchPullRequest?: GitManager["Service"]["branchPullRequest"]; + readonly summary?: PullRequestService["Service"]["summary"]; + readonly existingWorktrees?: ReadonlyArray; + readonly project?: OrchestrationProjectShell; + readonly resolveRepositoryIdentity?: RepositoryIdentityResolver["Service"]["resolve"]; +}) { + const activation = yield* Deferred.make(); + const snapshots = yield* Ref.make({ + snapshotSequence: 1, + projects: [options.project ?? project], + threads: options.threads, + updatedAt: NOW, + }); + const reads = yield* Queue.unbounded(); + const events = yield* PubSub.unbounded(); + const commands = yield* Ref.make>([]); + const branchCalls = yield* Ref.make< + ReadonlyArray<{ readonly cwd: string; readonly branch: string; readonly refresh: boolean }> + >([]); + const summaryCalls = yield* Ref.make>([]); + let uuid = 0; + const dependencies = Layer.mergeAll( + Layer.mock(ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Ref.get(snapshots).pipe(Effect.tap(() => Queue.offer(reads, undefined))), + }), + Layer.mock(GitManager)({ + branchPullRequest: (input, readOptions) => + Ref.update(branchCalls, (calls) => [ + ...calls, + { ...input, refresh: readOptions?.refresh === true }, + ]).pipe( + Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), + ), + }), + Layer.mock(PullRequestService)({ + summary: (input, readOptions) => + Ref.update(summaryCalls, (calls) => [...calls, input]).pipe( + Effect.andThen( + options.summary?.(input, readOptions) ?? Effect.succeed(summary(input, "open")), + ), + ), + }), + Layer.mock(RepositoryIdentityResolver)({ + resolve: + options.resolveRepositoryIdentity ?? + (() => Effect.succeed(options.project?.repositoryIdentity ?? project.repositoryIdentity)), + }), + Layer.mock(OrchestrationEngineService)({ + subscribeDomainEvents: PubSub.subscribe(events).pipe( + Effect.map((subscription) => Stream.fromSubscription(subscription)), + ), + dispatch: (command) => { + if (command.type !== "thread.pull-request.sync") { + return Effect.die(`Unexpected command: ${command.type}`); + } + return Ref.update(commands, (current) => [...current, command]).pipe( + Effect.andThen( + Ref.updateAndGet(snapshots, (snapshot) => ({ + ...snapshot, + snapshotSequence: snapshot.snapshotSequence + 1, + threads: snapshot.threads.map((current) => + current.id === command.threadId + ? { + ...current, + branchPullRequest: command.branchPullRequest, + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), + } + : current, + ), + })), + ), + Effect.map((snapshot) => ({ sequence: snapshot.snapshotSequence })), + ); + }, + }), + Layer.succeed(ServerActivation, Deferred.await(activation)), + Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size).fill(++uuid), + digest: (_algorithm, data) => Effect.succeed(data), + }), + ), + FileSystem.layerNoop({ + exists: (path) => Effect.succeed(options.existingWorktrees?.includes(path) ?? false), + }), + ); + + const start = Effect.fn("startThreadPullRequestHarness")(function* () { + const reactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor; + yield* reactor.start(); + yield* Deferred.succeed(activation, undefined); + yield* Queue.take(reads); + yield* reactor.drain; + return reactor; + }); + + return { + start, + reads, + snapshots, + commands, + branchCalls, + summaryCalls, + publish: (event: OrchestrationEvent) => PubSub.publish(events, event), + layer: ThreadPullRequestReactor.layer.pipe(Layer.provide(dependencies)), + }; +}); + +describe("ThreadPullRequestReactor", () => { + it.effect("discovers saved branch PRs without a client and shares branch lookups", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("first"), + thread("second"), + thread("archived", { archivedAt: NOW }), + thread("no-branch", { branch: null }), + ], + branchPullRequest: () => Effect.succeed(branchPullRequest()), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.branchCalls)).toEqual([ + { cwd: project.workspaceRoot, branch: "feature", refresh: false }, + { cwd: project.workspaceRoot, branch: "feature", refresh: false }, + ]); + expect((yield* Ref.get(fixture.commands)).map((command) => command.threadId)).toEqual([ + "first", + "second", + ]); + expect((yield* Ref.get(fixture.snapshots)).threads[0]?.branchPullRequest).toEqual( + reference(42), + ); + + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect(yield* Ref.get(fixture.commands)).toHaveLength(2); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("replaces terminal manual links but preserves open links and explicit unlink", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("merged", { linkedPullRequest: reference(1) }), + thread("closed", { linkedPullRequest: reference(2) }), + thread("open", { linkedPullRequest: reference(3) }), + thread("unlinked", { linkedPullRequest: null }), + ], + branchPullRequest: () => Effect.succeed(branchPullRequest()), + summary: (input) => + Effect.succeed( + summary( + input, + input.number === 1 ? "merged" : input.number === 2 ? "closed" : "open", + ), + ), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + const snapshot = yield* Ref.get(fixture.snapshots); + expect(snapshot.threads.map((current) => current.linkedPullRequest)).toEqual([ + reference(42), + reference(42), + reference(3), + null, + ]); + expect( + snapshot.threads.every((current) => current.branchPullRequest?.number === 42), + ).toBe(true); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("refreshes discovery after a turn ends without client demand", () => + Effect.scoped( + Effect.gen(function* () { + const current = thread("turn-thread"); + const detected = yield* Ref.make(null); + const fixture = yield* makeHarness({ + threads: [current], + branchPullRequest: (_input, options) => + options?.refresh + ? Ref.set(detected, branchPullRequest()).pipe(Effect.andThen(Ref.get(detected))) + : Ref.get(detected), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* fixture.publish({ + type: "thread.session-set", + sequence: 2, + eventId: EventId.make("turn-finished"), + aggregateKind: "thread", + aggregateId: current.id, + occurredAt: NOW, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + threadId: current.id, + session: { + threadId: current.id, + status: "ready", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }, + }); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual(reference(42)); + expect((yield* Ref.get(fixture.branchCalls)).filter((call) => call.refresh)).toHaveLength( + 1, + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("uses live worktrees and falls back to the project for removed worktrees", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("live", { worktreePath: "/workspace/worktree" }), + thread("removed", { worktreePath: "/workspace/removed" }), + ], + existingWorktrees: ["/workspace/worktree"], + branchPullRequest: () => Effect.succeed(branchPullRequest()), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect(new Set((yield* Ref.get(fixture.branchCalls)).map((call) => call.cwd))).toEqual( + new Set(["/workspace/project", "/workspace/worktree"]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("retains only terminal PRs on shared checkouts and clears a removed branch", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("terminal", { branch: "main", branchPullRequest: reference(1) }), + thread("open", { branchPullRequest: reference(2) }), + thread("worktree", { + worktreePath: "/workspace/worktree", + branchPullRequest: reference(1), + }), + thread("cleared", { + branch: null, + branchPullRequest: reference(1), + linkedPullRequest: reference(3), + }), + ], + summary: (input) => + Effect.succeed(summary(input, input.number === 1 ? "merged" : "open")), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + const snapshot = yield* Ref.get(fixture.snapshots); + expect(snapshot.threads.map((current) => current.branchPullRequest)).toEqual([ + reference(1), + null, + null, + null, + ]); + expect(snapshot.threads[3]?.linkedPullRequest).toEqual(reference(3)); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("keeps saved links on lookup failures and rejects a different repository", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [ + thread("failed", { branch: "failed", branchPullRequest: reference(1) }), + thread("wrong-repository", { + branch: "wrong-repository", + branchPullRequest: reference(2), + }), + thread("healthy"), + ], + branchPullRequest: ({ cwd, branch }) => + branch === "failed" + ? Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Lookup failed", + }), + ) + : Effect.succeed({ + ...branchPullRequest(), + repositoryKey: + branch === "wrong-repository" ? "github.com/other/repository" : REPOSITORY_KEY, + }), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect( + (yield* Ref.get(fixture.snapshots)).threads.map((current) => current.branchPullRequest), + ).toEqual([reference(1), reference(2), reference(42)]); + expect(yield* Ref.get(fixture.commands)).toHaveLength(1); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("retries failed settled backfills and stops querying them after success", () => + Effect.scoped( + Effect.gen(function* () { + const online = yield* Ref.make(false); + const fixture = yield* makeHarness({ + threads: [ + thread("backfill", { settledOverride: "settled", settledAt: NOW }), + thread("known", { + branch: "known", + settledOverride: "settled", + settledAt: NOW, + branchPullRequest: reference(1), + }), + ], + branchPullRequest: ({ cwd }) => + Ref.get(online).pipe( + Effect.flatMap((connected) => + connected + ? Effect.succeed(branchPullRequest(42, "merged")) + : Effect.fail( + new GitManagerError({ + operation: "branchPullRequest", + cwd, + detail: "Offline", + }), + ), + ), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + yield* Ref.set(online, true); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.commands))[0]?.threadId).toBe("backfill"); + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + expect((yield* Ref.get(fixture.branchCalls)).map((call) => call.branch)).toEqual([ + "feature", + "feature", + "feature", + ]); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("stops retrying a settled backfill after repeated lookup failures", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [thread("backfill", { settledOverride: "settled", settledAt: NOW })], + branchPullRequest: ({ cwd }) => + Effect.fail( + new GitManagerError({ operation: "branchPullRequest", cwd, detail: "No gh" }), + ), + }); + yield* Effect.gen(function* () { + const reactor = yield* fixture.start(); + for ( + let attempt = 1; + attempt < ThreadPullRequestReactor.BACKFILL_ATTEMPTS + 2; + attempt++ + ) { + yield* TestClock.adjust("1 minute"); + yield* Queue.take(fixture.reads); + yield* reactor.drain; + } + expect(yield* Ref.get(fixture.branchCalls)).toHaveLength( + ThreadPullRequestReactor.BACKFILL_ATTEMPTS, + ); + expect(yield* Ref.get(fixture.commands)).toHaveLength(0); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect("matches Azure SSH projects to HTTPS PRs with the provider repository selector", () => + Effect.scoped( + Effect.gen(function* () { + const fixture = yield* makeHarness({ + threads: [thread("azure")], + project: { + ...project, + repositoryIdentity: { + canonicalKey: "ssh.dev.azure.com/v3/org/project/repository", + displayName: "v3/org/project/repository", + name: "repository", + provider: "azure-devops", + rootPath: project.workspaceRoot, + locator: { + source: "git-remote", + remoteName: "origin", + remoteUrl: "git@ssh.dev.azure.com:v3/org/project/repository", + }, + }, + }, + branchPullRequest: () => + Effect.succeed({ + ...branchPullRequest(), + repositoryKey: "dev.azure.com/org/project/_git/repository", + url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", + }), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect((yield* Ref.get(fixture.commands))[0]?.branchPullRequest).toEqual({ + projectId: PROJECT_ID, + repository: "repository", + number: 42, + url: "https://dev.azure.com/org/project/_git/repository/pullrequest/42", + }); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + + it.effect.each(["primary", "branch"] as const)( + "rejects the group's links if the %s remote changes during a summary read", + (changedRemote) => + Effect.scoped( + Effect.gen(function* () { + const identity = yield* Ref.make(project.repositoryIdentity); + const detected = yield* Ref.make(branchPullRequest()); + const fixture = yield* makeHarness({ + threads: [thread("manual", { linkedPullRequest: reference(1) }), thread("automatic")], + branchPullRequest: () => Ref.get(detected), + resolveRepositoryIdentity: (_cwd, options) => + options?.refresh ? Ref.get(identity) : Effect.succeed(project.repositoryIdentity), + summary: (input) => + (changedRemote === "primary" + ? Ref.set(identity, { + ...project.repositoryIdentity, + canonicalKey: "github.com/other/repository", + displayName: "other/repository", + }) + : Ref.set(detected, { + ...branchPullRequest(99), + repositoryKey: "github.com/other/repository", + url: "https://github.com/other/repository/pull/99", + }) + ).pipe(Effect.as(summary(input, "merged"))), + }); + yield* Effect.gen(function* () { + yield* fixture.start(); + expect(yield* Ref.get(fixture.commands)).toEqual([]); + expect((yield* Ref.get(fixture.snapshots)).threads[0]?.linkedPullRequest).toEqual( + reference(1), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration/ThreadPullRequestReactor.ts b/apps/server/src/orchestration/ThreadPullRequestReactor.ts new file mode 100644 index 000000000000..6e2aa0b3e054 --- /dev/null +++ b/apps/server/src/orchestration/ThreadPullRequestReactor.ts @@ -0,0 +1,371 @@ +import { + CommandId, + type OrchestrationEvent, + type OrchestrationProjectShell, + type ThreadId, + type ThreadLinkedPullRequest, +} 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 Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +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 RepositoryIdentityResolver from "../project/RepositoryIdentityResolver.ts"; +import { forkParked } from "../serverActivation.ts"; +import * as OrchestrationEngine from "./Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "./Services/ProjectionSnapshotQuery.ts"; + +export class ThreadPullRequestReactor extends Context.Service< + ThreadPullRequestReactor, + { + readonly start: () => Effect.Effect; + readonly drain: Effect.Effect; + } +>()("t3/orchestration/ThreadPullRequestReactor") {} + +function samePullRequest( + left: ThreadLinkedPullRequest | null | undefined, + right: ThreadLinkedPullRequest | null, +): boolean { + if (left == null || right === null) return left == null && right === null; + return ( + left.projectId === right.projectId && + left.repository.toLowerCase() === right.repository.toLowerCase() && + left.number === right.number && + left.url === right.url + ); +} + +/** Startup lookups per settled thread before discovery gives up on it. */ +export const BACKFILL_ATTEMPTS = 5; + +interface RefreshRequest { + readonly threadId: ThreadId | null; + readonly refresh: boolean; + readonly backfill?: boolean; +} + +function canonicalRepositoryKey(key: string): string { + return key + .replace( + /^(?:ssh\.dev\.azure\.com|vs-ssh\.visualstudio\.com)\/v3\/([^/]+)\/([^/]+)\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ) + .replace( + /^([^.]+)\.visualstudio\.com\/(?:defaultcollection\/)?([^/]+)\/_git\/([^/]+)$/u, + "dev.azure.com/$1/$2/_git/$3", + ); +} + +export function pullRequestMatchesProject( + pullRequest: GitManager.GitBranchPullRequest, + project: OrchestrationProjectShell, +): boolean { + return ( + pullRequest.repositoryKey !== null && + project.repositoryIdentity != null && + canonicalRepositoryKey(pullRequest.repositoryKey) === + canonicalRepositoryKey(project.repositoryIdentity.canonicalKey) + ); +} + +export const make = Effect.gen(function* () { + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const git = yield* GitManager.GitManager; + const pullRequests = yield* PullRequestService.PullRequestService; + const repositoryIdentities = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + // Settled threads get one link discovery at startup. Failed lookups retry on + // the periodic pass a few times, then stop until the thread changes or the + // server restarts, so a missing or logged-out CLI cannot loop forever. + const pendingBackfill = new Map(); + const finishBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { + for (const thread of threads) pendingBackfill.delete(thread.id); + }; + const failBackfill = (threads: ReadonlyArray<{ readonly id: ThreadId }>) => { + for (const thread of threads) { + const remaining = pendingBackfill.get(thread.id); + if (remaining === undefined) continue; + if (remaining <= 1) pendingBackfill.delete(thread.id); + else pendingBackfill.set(thread.id, remaining - 1); + } + }; + + const synchronize = Effect.fn("ThreadPullRequestReactor.synchronize")(function* ( + request: RefreshRequest, + ) { + const snapshot = yield* snapshots.getShellSnapshot(); + const projects = new Map(snapshot.projects.map((project) => [project.id, project])); + if (request.backfill) { + for (const thread of snapshot.threads) { + if (thread.settledOverride === "settled" && thread.branchPullRequest == null) { + pendingBackfill.set(thread.id, BACKFILL_ATTEMPTS); + } + } + } + const threadIds = new Set(snapshot.threads.map((thread) => thread.id)); + for (const threadId of pendingBackfill.keys()) { + if (!threadIds.has(threadId)) pendingBackfill.delete(threadId); + } + const threads = snapshot.threads.filter( + (thread) => + thread.archivedAt === null && + (request.threadId === null || thread.id === request.threadId) && + (thread.settledOverride !== "settled" || + request.threadId !== null || + pendingBackfill.has(thread.id)) && + (thread.branch !== null || thread.branchPullRequest != null), + ); + const groups = Map.groupBy(threads, (thread) => + JSON.stringify([thread.projectId, thread.worktreePath, thread.branch]), + ); + + yield* Effect.forEach( + groups.values(), + (group) => + Effect.gen(function* () { + const first = group[0]!; + const project = projects.get(first.projectId); + if (project === undefined) return finishBackfill(group); + const repository = PullRequestService.repositoryIdentityOf(project); + if (first.branch !== null && repository === null) return finishBackfill(group); + const worktreeExists = + first.worktreePath !== null && (yield* fileSystem.exists(first.worktreePath)); + const cwd = + worktreeExists && first.worktreePath !== null + ? first.worktreePath + : project.workspaceRoot; + const detected = + first.branch === null + ? null + : yield* git.branchPullRequest( + { cwd, branch: first.branch }, + { refresh: request.refresh }, + ); + // A worktree can have different remotes, and the project identity + // can lag a remote edit. Do not attach its PR to the wrong repository. + if (detected !== null && !pullRequestMatchesProject(detected, project)) { + return finishBackfill(group); + } + const detectedReference = + detected !== null && repository !== null + ? { + projectId: project.id, + repository, + number: detected.number, + url: detected.url, + } + : null; + + const plans = yield* Effect.forEach(group, (thread) => + Effect.gen(function* () { + let branchPullRequest = detectedReference; + // Shared checkouts often return to the default branch after + // a merge. Keep that thread's terminal PR across the change. + if ( + branchPullRequest === null && + thread.branch !== null && + thread.worktreePath === null && + thread.branchPullRequest != null + ) { + const previous = yield* pullRequests.summary(thread.branchPullRequest, { + recoverTransientFailure: false, + }); + if (previous.state === "merged" || previous.state === "closed") { + branchPullRequest = thread.branchPullRequest; + } + } + + let replacement: ThreadLinkedPullRequest | undefined; + if ( + thread.linkedPullRequest != null && + detected?.state === "open" && + detectedReference !== null && + !samePullRequest(thread.linkedPullRequest, detectedReference) + ) { + const linked = yield* pullRequests.summary(thread.linkedPullRequest, { + recoverTransientFailure: false, + }); + if (linked.state === "merged" || linked.state === "closed") { + replacement = detectedReference; + } + } + + if ( + samePullRequest(thread.branchPullRequest, branchPullRequest) && + replacement === undefined + ) { + pendingBackfill.delete(thread.id); + return null; + } + return { thread, branchPullRequest, replacement }; + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request discovery failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe( + Effect.tap(() => Effect.sync(() => failBackfill([thread]))), + Effect.as(null), + ), + ), + ), + ); + const updates = plans.filter((plan) => plan !== null); + if (updates.length === 0) return; + + if (detected !== null && first.branch !== null) { + // Summary reads can outlast a remote edit. Recheck the branch and + // the project's primary remote before saving the group's links. + const current = yield* git.branchPullRequest({ cwd, branch: first.branch }); + const currentIdentity = yield* repositoryIdentities.resolve(project.workspaceRoot, { + refresh: true, + }); + if ( + current === null || + current.number !== detected.number || + current.url !== detected.url || + current.state !== detected.state || + current.repositoryKey !== detected.repositoryKey || + !pullRequestMatchesProject(current, { + ...project, + repositoryIdentity: currentIdentity, + }) + ) { + return failBackfill(updates.map((update) => update.thread)); + } + } + + yield* Effect.forEach( + updates, + ({ thread, branchPullRequest, replacement }) => + Effect.gen(function* () { + const uuid = yield* crypto.randomUUIDv4; + yield* engine.dispatch({ + type: "thread.pull-request.sync", + commandId: CommandId.make(`server:thread-pull-request:${thread.id}:${uuid}`), + threadId: thread.id, + projectId: project.id, + snapshotSequence: snapshot.snapshotSequence, + expected: { + workspaceRoot: project.workspaceRoot, + branch: thread.branch, + worktreePath: thread.worktreePath, + linkedPullRequest: thread.linkedPullRequest ?? null, + branchPullRequest: thread.branchPullRequest ?? null, + }, + branchPullRequest, + ...(replacement !== undefined ? { linkedPullRequest: replacement } : {}), + }); + pendingBackfill.delete(thread.id); + }).pipe( + // The thread changed since the lookup. Its own events requeue it. + Effect.catchTags({ + OrchestrationCommandInvariantError: () => + Effect.sync(() => finishBackfill([thread])), + }), + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request update failed", { + threadId: thread.id, + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill([thread])))), + ), + ), + { discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread branch pull request lookup failed", { + threadIds: group.map((thread) => thread.id), + cause: Cause.pretty(cause), + }).pipe(Effect.tap(() => Effect.sync(() => failBackfill(group)))), + ), + ), + { concurrency: 8, discard: true }, + ); + }); + + const worker = yield* makeDrainableWorker((request: RefreshRequest) => + synchronize(request).pipe( + Effect.catchCause((cause) => + Cause.hasInterruptsOnly(cause) + ? Effect.failCause(cause) + : Effect.logWarning("thread pull request refresh failed", { + cause: Cause.pretty(cause), + }), + ), + ), + ); + + const processEvent = (event: OrchestrationEvent) => { + switch (event.type) { + case "thread.created": + case "thread.unarchived": + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); + case "thread.meta-updated": + if ( + event.payload.branchPullRequest === undefined && + (event.payload.branch !== undefined || + event.payload.worktreePath !== undefined || + event.payload.linkedPullRequest !== undefined) + ) { + return worker.enqueue({ threadId: event.payload.threadId, refresh: false }); + } + break; + case "thread.session-set": + if ( + event.payload.session.status !== "running" && + event.payload.session.status !== "starting" + ) { + return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + } + break; + case "thread.turn-diff-completed": + case "thread.unsettled": + return worker.enqueue({ threadId: event.payload.threadId, refresh: true }); + case "project.meta-updated": + if (event.payload.workspaceRoot !== undefined) { + return worker.enqueue({ threadId: null, refresh: false }); + } + break; + } + return Effect.void; + }; + + const start = Effect.fn("ThreadPullRequestReactor.start")(function* () { + const events = yield* engine.subscribeDomainEvents; + yield* forkParked(Stream.runForEach(events, processEvent)); + // Run without client demand. Saved branch lookups share GitManager's + // provider cache and retry backoff with status and automatic settlement. + yield* forkParked( + Effect.gen(function* () { + yield* worker.enqueue({ threadId: null, refresh: false, backfill: true }); + yield* worker.drain; + yield* Effect.gen(function* () { + yield* worker.enqueue({ threadId: null, refresh: false }); + yield* worker.drain; + }).pipe(Effect.repeat(Schedule.spaced("1 minute")), Effect.delay("1 minute")); + }).pipe(Effect.asVoid), + ); + }); + + return { start, drain: worker.drain } satisfies ThreadPullRequestReactor["Service"]; +}); + +export const layer = Layer.effect(ThreadPullRequestReactor, make); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts index 50cabe90c1d3..f2038bfe689d 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.test.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.test.ts @@ -25,7 +25,7 @@ import * as Ref from "effect/Ref"; import * as Stream from "effect/Stream"; import { TestClock } from "effect/testing"; -import { GitManager } from "../git/GitManager.ts"; +import { GitManager, type GitBranchPullRequest } from "../git/GitManager.ts"; import { PullRequestService, type PullRequestMergeEvent, @@ -132,6 +132,24 @@ function makePullRequestSummary(input: { }; } +function makeBranchPullRequest( + state: GitBranchPullRequest["state"], + updatedAt: string | null = NOW, +): GitBranchPullRequest { + return { + number: 42, + title: "Branch pull request", + url: "https://example.test/owner/repository/pull/42", + baseRef: "main", + headRef: "saved-feature", + repositoryKey: "example.test/owner/repository", + state, + updatedAt, + closedAt: state === "closed" ? updatedAt : null, + mergedAt: state === "merged" ? updatedAt : null, + }; +} + interface HarnessOptions { readonly snapshot: OrchestrationShellSnapshot; readonly settings?: ServerSettings; @@ -173,9 +191,9 @@ const makeHarness = Effect.fn("makeThreadSettlementHarness")(function* (options: return next; }); - const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input) => + const branchPullRequest: GitManager["Service"]["branchPullRequest"] = (input, readOptions) => Ref.update(branchCalls, (calls) => [...calls, input]).pipe( - Effect.andThen(options.branchPullRequest?.(input) ?? Effect.succeed(null)), + Effect.andThen(options.branchPullRequest?.(input, readOptions) ?? Effect.succeed(null)), ); const pullRequestSummary: PullRequestService["Service"]["summary"] = (input, readOptions) => Effect.gen(function* () { @@ -281,6 +299,83 @@ const startHarness = Effect.fn("startThreadSettlementHarness")(function* ( }); describe("ThreadSettlementReactor", () => { + it.effect("uses saved PRs without settling resumed threads or branches with newer PRs", () => + Effect.scoped( + Effect.gen(function* () { + yield* TestClock.setTime(Date.parse(NOW)); + const previous = { + projectId: PROJECT_ID, + repository: "owner/repository", + number: 1, + url: "https://example.test/owner/repository/pull/1", + }; + const project = { + ...makeProject(), + repositoryIdentity: { + canonicalKey: "example.test/owner/repository", + rootPath: "/workspace/project", + displayName: "owner/repository", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "https://example.test/owner/repository.git", + }, + }, + }; + const fixture = yield* makeHarness({ + snapshot: makeSnapshot( + [ + makeThread("retained-terminal", { branch: "main", branchPullRequest: previous }), + makeThread("reused-manual", { branch: "reused", linkedPullRequest: previous }), + makeThread("reused-detected", { branch: "reused", branchPullRequest: previous }), + makeThread("foreign-branch-pr", { branch: "foreign", linkedPullRequest: previous }), + makeThread("resumed-manual", { + branch: "main", + linkedPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + makeThread("resumed-detected", { + branch: "main", + branchPullRequest: previous, + latestUserMessageAt: "2026-08-28T00:00:00.000Z", + }), + ], + [project], + ), + settings: { + ...DEFAULT_SERVER_SETTINGS, + sidebarAutoSettleAfterDays: null, + sidebarAutoSettleOnMerge: true, + }, + branchPullRequest: ({ branch }, options) => + Effect.succeed( + branch === "reused" + ? makeBranchPullRequest(options?.refresh ? "open" : "merged") + : branch === "foreign" + ? { + ...makeBranchPullRequest("open"), + repositoryKey: "example.test/another/repository", + } + : null, + ), + pullRequestSummary: (input) => + Effect.succeed({ + ...makePullRequestSummary({ ...input, state: "merged" }), + mergedAt: "2026-08-27T00:00:00.000Z", + }), + }); + yield* Effect.gen(function* () { + const reactor = yield* ThreadSettlementReactor.ThreadSettlementReactor; + yield* startHarness(reactor, fixture.activation, fixture.snapshotReads); + assert.deepStrictEqual( + new Set((yield* Ref.get(fixture.commands)).map((command) => command.threadId)), + new Set([ThreadId.make("retained-terminal"), ThreadId.make("foreign-branch-pr")]), + ); + }).pipe(Effect.provide(fixture.layer)); + }), + ), + ); + it.effect("starts without clients and skips protected threads before pull request lookup", () => Effect.scoped( Effect.gen(function* () { @@ -375,9 +470,7 @@ describe("ThreadSettlementReactor", () => { }), ]), branchPullRequest: () => - Ref.get(pullRequest).pipe( - Effect.map((state) => ({ state, updatedAt: NOW, closedAt: NOW, mergedAt: NOW })), - ), + Ref.get(pullRequest).pipe(Effect.map((state) => makeBranchPullRequest(state))), }); yield* Effect.gen(function* () { @@ -430,10 +523,10 @@ describe("ThreadSettlementReactor", () => { Ref.updateAndGet(branchLookupCount, (count) => count + 1).pipe( Effect.flatMap((count) => count === 1 - ? Effect.succeed({ state: "open" as const, updatedAt: NOW }) + ? Effect.succeed(makeBranchPullRequest("open")) : Deferred.succeed(periodicLookupStarted, undefined).pipe( Effect.andThen(Deferred.await(releasePeriodicLookup)), - Effect.as({ state: "open" as const, updatedAt: NOW }), + Effect.as(makeBranchPullRequest("open")), ), ), ), @@ -477,12 +570,7 @@ describe("ThreadSettlementReactor", () => { ]), branchPullRequest: () => Ref.get(state).pipe( - Effect.map((pullRequestState) => ({ - state: pullRequestState, - updatedAt: NOW, - closedAt: NOW, - mergedAt: NOW, - })), + Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), ), onDispatch: () => Deferred.succeed(mergedThreadSettled, undefined), }); @@ -618,12 +706,7 @@ describe("ThreadSettlementReactor", () => { : Effect.void, ), Effect.andThen(Ref.get(state)), - Effect.map((pullRequestState) => ({ - state: pullRequestState, - updatedAt: NOW, - closedAt: NOW, - mergedAt: NOW, - })), + Effect.map((pullRequestState) => makeBranchPullRequest(pullRequestState)), ), }); @@ -896,8 +979,7 @@ describe("ThreadSettlementReactor", () => { makeProject(LINKED_PROJECT_ID, "/workspace/linked-root"), ], ), - branchPullRequest: () => - Effect.succeed({ state: "closed", updatedAt: NOW, closedAt: NOW }), + branchPullRequest: () => Effect.succeed(makeBranchPullRequest("closed")), pullRequestSummary: (input) => Effect.succeed(makePullRequestSummary({ ...input, state: "merged" })), }); diff --git a/apps/server/src/orchestration/ThreadSettlementReactor.ts b/apps/server/src/orchestration/ThreadSettlementReactor.ts index 77a6546365ff..994830b30a47 100644 --- a/apps/server/src/orchestration/ThreadSettlementReactor.ts +++ b/apps/server/src/orchestration/ThreadSettlementReactor.ts @@ -17,6 +17,7 @@ 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 { pullRequestMatchesProject } from "./ThreadPullRequestReactor.ts"; import { isAutoSettlementCandidate, resolveAutoSettlementAt, @@ -46,10 +47,8 @@ export const make = Effect.gen(function* () { const snapshot = yield* snapshots.getShellSnapshot(); const now = DateTime.formatIso(yield* DateTime.now); const projects = new Map(snapshot.projects.map((project) => [project.id, project])); - // A merge event re-sweeps every candidate, not just the threads linked to - // the merged pull request: most threads carry no link and settle from - // their branch lookup, which would otherwise wait for the next minute's - // sweep on a possibly stale cached answer. + // A merge rechecks all candidates, including branches that discovery has + // not linked yet. Those lookups can still have cached the PR as open. const candidates = snapshot.threads.filter((thread) => isAutoSettlementCandidate(thread, now)); // Return the thread when it still needs a pull request decision. A rejected @@ -101,14 +100,14 @@ export const make = Effect.gen(function* () { }, )).filter((thread) => thread !== null); - // Use the same cwd as the sidebar so both paths share GitManager's PR cache. + // Use the same cwd as PR discovery so both paths share GitManager's cache. const lookupCwdByThreadId = new Map(); yield* Effect.forEach( lookupCandidates, (thread) => Effect.gen(function* () { const project = projects.get(thread.projectId); - if (project === undefined || thread.linkedPullRequest != null) return; + if (project === undefined || thread.branch === null) return; const worktreeExists = thread.worktreePath !== null && (yield* fileSystem.exists(thread.worktreePath).pipe(Effect.orElseSucceed(() => false))); @@ -122,12 +121,8 @@ export const make = Effect.gen(function* () { { concurrency: 8, discard: true }, ); if (mergedPullRequest !== null) { - // The merge just confirmed a terminal state the lookup caches can still - // call open (branch answers live two minutes, the sweep runs every - // minute). Drop the swept checkouts' cached answers so the merge settles - // its branch threads now instead of on a later sweep. Threads linked to - // the merged pull request settle from the event itself below and need no - // lookup, so they are absent from this map by construction. + // The merge confirmed a state the branch cache can still call open. + // Recheck those branches now instead of waiting for cache expiry. const cwds = [...new Set(lookupCwdByThreadId.values())]; yield* Effect.forEach(cwds, (cwd) => git.invalidateStatus(cwd), { concurrency: 8, @@ -135,12 +130,15 @@ export const make = Effect.gen(function* () { }); } const lookupKey = (thread: (typeof candidates)[number]) => { - if (thread.linkedPullRequest != null) { + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + if (reference != null) { return JSON.stringify([ "linked", - thread.linkedPullRequest.projectId, - thread.linkedPullRequest.repository, - thread.linkedPullRequest.number, + reference.projectId, + reference.repository, + reference.number, + lookupCwdByThreadId.get(thread.id), + thread.branch, ]); } if (thread.branch === null) return JSON.stringify(["none", thread.id]); @@ -154,35 +152,47 @@ export const make = Effect.gen(function* () { const pullRequestFor = Effect.fn("ThreadSettlementReactor.pullRequestFor")(function* ( thread: (typeof candidates)[number], ) { - if (thread.linkedPullRequest != null) { - // The event carries the merged state, so only the threads linked to - // that exact pull request settle from it. Every other linked thread - // falls through to a fresh summary lookup below: the merge sweep - // covers all candidates, and an unrelated merge must never settle - // them. - if ( + const reference = thread.linkedPullRequest ?? thread.branchPullRequest; + if (reference != null) { + const matchesMerge = mergedPullRequest !== null && - thread.linkedPullRequest.projectId === mergedPullRequest.projectId && - thread.linkedPullRequest.repository.toLowerCase() === - mergedPullRequest.repository.toLowerCase() && - thread.linkedPullRequest.number === mergedPullRequest.number - ) { - return { - state: "merged", - mergedAt: mergedPullRequest.mergedAt, - } satisfies SettlementPullRequest; - } - if (!projects.has(thread.linkedPullRequest.projectId)) { + reference.projectId === mergedPullRequest.projectId && + reference.repository.toLowerCase() === mergedPullRequest.repository.toLowerCase() && + reference.number === mergedPullRequest.number; + if (!matchesMerge && !projects.has(reference.projectId)) { return yield* Effect.die(new Error("linked pull request project not found")); } - const summary = yield* pullRequests.summary( - { - projectId: thread.linkedPullRequest.projectId, - repository: thread.linkedPullRequest.repository, - number: thread.linkedPullRequest.number, - }, - { recoverTransientFailure: false }, - ); + const summary = matchesMerge + ? ({ + state: "merged", + closedAt: null, + mergedAt: mergedPullRequest.mergedAt, + } satisfies SettlementPullRequest) + : yield* pullRequests.summary( + { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + }, + { recoverTransientFailure: false }, + ); + const cwd = lookupCwdByThreadId.get(thread.id); + if (summary.state !== "open" && thread.branch !== null && cwd !== undefined) { + // A reused branch can already have a new open PR while discovery + // is replacing its old link. Do not let settlement win that race. + const current = yield* git.branchPullRequest( + { cwd, branch: thread.branch }, + { refresh: true }, + ); + const project = projects.get(thread.projectId); + if ( + current?.state === "open" && + project !== undefined && + pullRequestMatchesProject(current, project) + ) { + return current; + } + } return { state: summary.state, closedAt: summary.closedAt ?? null, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 1fd9feba7c44..a20dba468c65 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,6 +1,7 @@ import { EventId, MessageId, + ThreadLinkedPullRequest, UserInputRequestedPayload, isImportedAgentSessionMessageId, type OrchestrationCommand, @@ -38,6 +39,7 @@ import { threadHasQueuedTurnStart } from "./ThreadSettlementPolicy.ts"; const nowIso = Effect.map(DateTime.now, DateTime.formatIso); const decodeUserInputRequestedPayload = Schema.decodeUnknownOption(UserInputRequestedPayload); +const threadPullRequestLinksEqual = Schema.toEquivalence(Schema.NullOr(ThreadLinkedPullRequest)); /** * Blocked-on-you work derived from the thread's retained activities: an @@ -836,6 +838,63 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.pull-request.sync": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + if (thread.deletedAt !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} was deleted before pull request discovery`, + }); + } + if ( + thread.projectId !== command.projectId || + thread.branch !== command.expected.branch || + thread.worktreePath !== command.expected.worktreePath || + !threadPullRequestLinksEqual( + thread.linkedPullRequest ?? null, + command.expected.linkedPullRequest, + ) || + !threadPullRequestLinksEqual( + thread.branchPullRequest ?? null, + command.expected.branchPullRequest, + ) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} changed before pull request discovery`, + }); + } + const project = yield* requireProject({ readModel, command, projectId: command.projectId }); + if (project.deletedAt !== null || project.workspaceRoot !== command.expected.workspaceRoot) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `project ${command.projectId} changed before pull request discovery`, + }); + } + const occurredAt = yield* nowIso; + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + branchPullRequest: command.branchPullRequest, + ...(command.linkedPullRequest !== undefined + ? { linkedPullRequest: command.linkedPullRequest } + : {}), + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.title.regeneration.complete": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 4b37137936f4..d4e1213b2abb 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -86,6 +86,7 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + branchPullRequest: null, latestTurn: null, createdAt: now, updatedAt: now, @@ -105,6 +106,67 @@ describe("orchestration projector", () => { ]); }); + effectIt.effect("sets and clears branch pull requests without changing manual links", () => + Effect.gen(function* () { + const now = "2026-01-01T00:00:00.000Z"; + const eventFields = { + aggregateKind: "thread" as const, + aggregateId: "thread-1", + occurredAt: now, + commandId: null, + }; + let model = yield* projectEvent( + createEmptyReadModel(now), + makeEvent({ + ...eventFields, + sequence: 1, + type: "thread.created", + payload: { + threadId: "thread-1", + projectId: "project-1", + title: "Pull request thread", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: "feature", + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }), + ); + const linkedPullRequest = { + projectId: "project-1", + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const branchPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const updates = [ + { payload: { linkedPullRequest, branchPullRequest }, expected: branchPullRequest }, + { payload: { title: "Renamed thread" }, expected: branchPullRequest }, + { payload: { branchPullRequest: null }, expected: null }, + ]; + + for (const [index, update] of updates.entries()) { + model = yield* projectEvent( + model, + makeEvent({ + ...eventFields, + sequence: index + 2, + type: "thread.meta-updated", + payload: { threadId: "thread-1", updatedAt: now, ...update.payload }, + }), + ); + expect(model.threads[0]?.branchPullRequest).toEqual(update.expected); + expect(model.threads[0]?.linkedPullRequest).toEqual(linkedPullRequest); + } + }), + ); + it("fails when event payload cannot be decoded by runtime schema", async () => { const now = "2026-01-01T00:00:00.000Z"; const model = createEmptyReadModel(now); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 77dbe51b9542..58c4876905ec 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -336,6 +336,7 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + branchPullRequest: null, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -510,6 +511,9 @@ export function projectEvent( ...(payload.linkedPullRequest !== undefined ? { linkedPullRequest: payload.linkedPullRequest } : {}), + ...(payload.branchPullRequest !== undefined + ? { branchPullRequest: payload.branchPullRequest } + : {}), updatedAt: payload.updatedAt, }), })), diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 2f8285d8c1fd..68ceb8573b75 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -462,7 +462,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { }), ); - it.effect("round-trips a linked pull request through the thread row", () => + it.effect("round-trips manual and branch pull requests through the thread row", () => Effect.gen(function* () { const threads = yield* ProjectionThreadRepository; const linkedPullRequest = { @@ -471,6 +471,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { number: 42, url: "https://github.com/pingdotgg/t3code/pull/42", }; + const branchPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; yield* threads.upsert({ threadId: ThreadId.make("thread-linked-pr"), @@ -485,6 +490,7 @@ projectionRepositoriesLayer("Projection repositories", (it) => { branch: null, worktreePath: null, linkedPullRequest, + branchPullRequest, latestTurnId: null, createdAt: "2026-03-24T00:00:00.000Z", updatedAt: "2026-03-24T00:00:00.000Z", @@ -504,6 +510,10 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const persisted = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); assert.deepStrictEqual(Option.getOrNull(persisted)?.linkedPullRequest, linkedPullRequest); + assert.deepStrictEqual(Option.getOrNull(persisted)?.branchPullRequest, branchPullRequest); + + const listed = yield* threads.listByProjectId({ projectId: linkedPullRequest.projectId }); + assert.deepStrictEqual(listed[0]?.branchPullRequest, branchPullRequest); const row = Option.getOrNull(persisted); if (row === null) return yield* Effect.die("Expected linked thread row to exist."); @@ -511,6 +521,12 @@ projectionRepositoriesLayer("Projection repositories", (it) => { const cleared = yield* threads.getById({ threadId: ThreadId.make("thread-linked-pr") }); assert.strictEqual(Option.getOrNull(cleared)?.linkedPullRequest, null); + assert.deepStrictEqual(Option.getOrNull(cleared)?.branchPullRequest, branchPullRequest); + + yield* threads.upsert({ ...row, branchPullRequest: null }); + const branchCleared = yield* threads.getById({ threadId: row.threadId }); + assert.strictEqual(Option.getOrNull(branchCleared)?.branchPullRequest, null); + assert.deepStrictEqual(Option.getOrNull(branchCleared)?.linkedPullRequest, linkedPullRequest); }), ); }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index d5653a2c8b42..799386845419 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -20,6 +20,7 @@ const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), linkedPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), + branchPullRequest: Schema.NullOr(Schema.fromJsonString(ThreadLinkedPullRequest)), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; @@ -41,6 +42,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path, linked_pull_request_json, + branch_pull_request_json, latest_turn_id, created_at, updated_at, @@ -70,6 +72,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.branch}, ${row.worktreePath}, ${row.linkedPullRequest === undefined || row.linkedPullRequest === null ? null : JSON.stringify(row.linkedPullRequest)}, + ${row.branchPullRequest === undefined || row.branchPullRequest === null ? null : JSON.stringify(row.branchPullRequest)}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -99,6 +102,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch = excluded.branch, worktree_path = excluded.worktree_path, linked_pull_request_json = excluded.linked_pull_request_json, + branch_pull_request_json = excluded.branch_pull_request_json, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -135,6 +139,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -173,6 +178,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { branch, worktree_path AS "worktreePath", linked_pull_request_json AS "linkedPullRequest", + branch_pull_request_json AS "branchPullRequest", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 92dc18291057..7f89170d29f5 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -59,6 +59,7 @@ import Migration0044 from "./Migrations/044_ClearAutomaticProjectModelDefaults.t import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; +import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; /** * Migration loader with all migrations defined inline. @@ -118,6 +119,7 @@ export const migrationEntries = [ [45, "ProjectionProjectsAutoPull", Migration0045], [46, "RepairAutomaticSettlementTimestamps", Migration0046], [47, "ProjectionProjectIcon", Migration0047], + [48, "ProjectionThreadBranchPullRequest", Migration0048], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts b/apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts new file mode 100644 index 000000000000..49870b59635d --- /dev/null +++ b/apps/server/src/persistence/Migrations/048_ProjectionThreadBranchPullRequest.ts @@ -0,0 +1,16 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + + if (!columns.some((column) => column.name === "branch_pull_request_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN branch_pull_request_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index a70548bc110c..ee442624be51 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -35,6 +35,7 @@ export const ProjectionThread = Schema.Struct({ branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/project/RepositoryIdentityResolver.test.ts b/apps/server/src/project/RepositoryIdentityResolver.test.ts index 7c73752ee831..ac1cbfb44d0e 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.test.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.test.ts @@ -38,15 +38,16 @@ 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", () => { + it.effect("refreshes the Git root only when requested", () => { const calls: Array> = []; + let rootPath = "/repo"; const processRunner = Layer.succeed(ProcessRunner.ProcessRunner, { run: (input) => Effect.sync(() => { calls.push(input.args); return { stdout: input.args.includes("rev-parse") - ? "/repo\n" + ? `${rootPath}\n` : "origin\tgit@github.com:T3Tools/t3code.git (fetch)\n", stderr: "", code: ChildProcessSpawner.ExitCode(0), @@ -66,6 +67,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { return Effect.gen(function* () { const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; const first = yield* resolver.resolve("/repo/packages/web"); + rootPath = "/repo/packages/web"; const second = yield* resolver.resolve("/repo/packages/web"); expect(first?.canonicalKey).toBe("github.com/t3tools/t3code"); @@ -74,6 +76,14 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], ["-C", "/repo", "remote", "-v"], ]); + + const refreshed = yield* resolver.resolve("/repo/packages/web", { refresh: true }); + expect(refreshed?.rootPath).toBe("/repo/packages/web"); + expect(yield* resolver.resolve("/repo/packages/web")).toEqual(refreshed); + expect(calls.slice(2)).toEqual([ + ["-C", "/repo/packages/web", "rev-parse", "--show-toplevel"], + ["-C", "/repo/packages/web", "remote", "-v"], + ]); }).pipe(Effect.provide(resolverLayer)); }); @@ -197,25 +207,42 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => { }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); - it.effect("prefers upstream over origin when both remotes are configured", () => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const cwd = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-repository-identity-upstream-test-", - }); - - yield* git(cwd, ["init"]); - yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); - yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]); + it.effect.each(["add", "replace"] as const)( + "refreshes the primary upstream after %s before cache expiry", + (change) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-repository-identity-upstream-test-", + }); - const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; - const identity = yield* resolver.resolve(cwd); + yield* git(cwd, ["init"]); + yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]); + if (change === "replace") { + yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/previous.git"]); + } - expect(identity).not.toBeNull(); - expect(identity?.locator.remoteName).toBe("upstream"); - expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); - expect(identity?.displayName).toBe("t3tools/t3code"); - }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), + const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver; + const initialIdentity = yield* resolver.resolve(cwd); + expect(initialIdentity?.canonicalKey).toBe( + change === "add" ? "github.com/julius/t3code" : "github.com/t3tools/previous", + ); + + yield* git(cwd, [ + "remote", + change === "add" ? "add" : "set-url", + "upstream", + "git@github.com:T3Tools/t3code.git", + ]); + expect(yield* resolver.resolve(cwd)).toEqual(initialIdentity); + const identity = yield* resolver.resolve(cwd, { refresh: true }); + + expect(identity).not.toBeNull(); + expect(identity?.locator.remoteName).toBe("upstream"); + expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code"); + expect(identity?.displayName).toBe("t3tools/t3code"); + expect(yield* resolver.resolve(cwd)).toEqual(identity); + }).pipe(Effect.provide(RepositoryIdentityResolver.layer)), ); it.effect("uses the last remote path segment as the repository name for nested groups", () => diff --git a/apps/server/src/project/RepositoryIdentityResolver.ts b/apps/server/src/project/RepositoryIdentityResolver.ts index bf3c570c3cac..755008f6ded1 100644 --- a/apps/server/src/project/RepositoryIdentityResolver.ts +++ b/apps/server/src/project/RepositoryIdentityResolver.ts @@ -25,7 +25,10 @@ export interface RepositoryIdentityResolverOptions { export class RepositoryIdentityResolver extends Context.Service< RepositoryIdentityResolver, { - readonly resolve: (cwd: string) => Effect.Effect; + readonly resolve: ( + cwd: string, + options?: { readonly refresh?: boolean }, + ) => Effect.Effect; } >()("t3/project/RepositoryIdentityResolver") {} @@ -170,9 +173,11 @@ export const make = Effect.fn("RepositoryIdentityResolver.make")(function* ( const resolve: RepositoryIdentityResolver["Service"]["resolve"] = Effect.fn( "RepositoryIdentityResolver.resolve", - )(function* (cwd) { + )(function* (cwd, options) { + if (options?.refresh) yield* Cache.invalidate(repositoryRootCache, cwd); const cacheKey = yield* Cache.get(repositoryRootCache, cwd); if (cacheKey === null) return null; + if (options?.refresh) yield* Cache.invalidate(repositoryIdentityCache, cacheKey); return yield* Cache.get(repositoryIdentityCache, cacheKey); }); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index ce39ee64f51a..696210b36b86 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -71,6 +71,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; +import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { ProviderRegistryLive } from "./provider/Layers/ProviderRegistry.ts"; @@ -279,6 +280,7 @@ const ReactorLayerLive = Layer.empty.pipe( Layer.provideMerge(CheckpointReactorLive), Layer.provideMerge(ThreadDeletionReactorLive), Layer.provideMerge(ThreadSettlementReactor.layer), + Layer.provideMerge(ThreadPullRequestReactor.layer), Layer.provideMerge(AgentAwarenessRelay.layer.pipe(Layer.provide(ServerSecretStore.layer))), Layer.provideMerge(RuntimeReceiptBusLive), ); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index c3c59129ca3f..c952fab99d6a 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -47,11 +47,7 @@ import { sanitizeNewRefName, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; -import { - ChangeRequestStatusIcon, - prStatusIndicator, - resolveThreadPr, -} from "./ThreadStatusIndicators"; +import { ChangeRequestStatusIcon, prStatusIndicator } from "./ThreadStatusIndicators"; import { Button } from "./ui/button"; import { Switch } from "./ui/switch"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; @@ -617,13 +613,14 @@ export function BranchToolbarBranchSelector({ }); // PR pill shown next to the branch selector when the active branch has one. - const branchPr = resolveThreadPr({ - threadBranch: resolveBranchToolbarPrBranch({ - activeThreadBranch, - resolvedActiveBranch, - }), - gitStatus: branchStatusQuery.data ?? null, + const branchPrBranch = resolveBranchToolbarPrBranch({ + activeThreadBranch, + resolvedActiveBranch, }); + const branchPr = + branchPrBranch !== null && branchStatusQuery.data?.refName === branchPrBranch + ? (branchStatusQuery.data.pr ?? null) + : null; const branchPrStatus = prStatusIndicator(branchPr, branchStatusQuery.data?.sourceControlProvider); // Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's // state-description tooltip. diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f26f40dd2cdb..1dd686cc803e 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -60,6 +60,7 @@ import { shouldDockDraftHeroForSubmission, shouldReleaseTimelineAnchorForToolActivity, shouldOpenProactivePullRequest, + shouldRetargetThreadPullRequestPanel, shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, shouldShowBranchMismatchBanner, @@ -249,6 +250,40 @@ describe("proactive panels", () => { expect(shouldOpenProactivePullRequest("project:repo:42", null)).toBe(false); }); + it("follows a changed server PR link without replacing an unrelated open panel", () => { + const previous = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const current = { + ...previous, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const surface = { + id: "pull-request:previous", + kind: "pull-request", + projectId: previous.projectId, + repository: "PingDotGG/T3Code", + number: previous.number, + } satisfies RightPanelSurface; + + expect(shouldRetargetThreadPullRequestPanel(previous, current, surface)).toBe(true); + expect(shouldRetargetThreadPullRequestPanel(previous, previous, surface)).toBe(false); + expect(shouldRetargetThreadPullRequestPanel(previous, null, surface)).toBe(false); + expect( + shouldRetargetThreadPullRequestPanel(previous, current, { ...surface, number: 99 }), + ).toBe(false); + expect( + shouldRetargetThreadPullRequestPanel(previous, current, { + ...surface, + projectId: "another-project", + }), + ).toBe(false); + }); + it("opens the diff only when the observed running turn settles", () => { const turnId = TurnId.make("turn-1"); expect( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 08043bd1c6d9..649ffef081d2 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -15,6 +15,7 @@ import { type ScopedProjectRef, type ScopedThreadRef, type ThreadId, + type ThreadLinkedPullRequest, type TurnId, } from "@t3tools/contracts"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; @@ -132,6 +133,24 @@ export function observeProactivePanelUserChoice( }; } +/** Follow a changed server link only when the panel still shows the previous linked PR. */ +export function shouldRetargetThreadPullRequestPanel( + previous: ThreadLinkedPullRequest | null, + current: ThreadLinkedPullRequest | null, + surface: RightPanelSurface | null, +): boolean { + if (previous === null || current === null || surface?.kind !== "pull-request") return false; + const previousRepository = previous.repository.toLowerCase(); + return ( + (previous.projectId !== current.projectId || + previousRepository !== current.repository.toLowerCase() || + previous.number !== current.number) && + surface.projectId === previous.projectId && + surface.repository.toLowerCase() === previousRepository && + surface.number === previous.number + ); +} + export function shouldOpenProactiveTurnDiff(input: { previousRunningTurnId: TurnId | null | undefined; runningTurnId: TurnId | null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 3ff390c6640c..faeab7e75ac6 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -24,6 +24,7 @@ import { type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, + type ThreadLinkedPullRequest, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, @@ -188,7 +189,7 @@ import { isThreadOwnPullRequest } from "./pullRequest/pullRequestDetail.logic"; import { PullRequestDetailPanel } from "./pullRequest/PullRequestDetailPanel"; import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; import { PullRequestsUnavailableState } from "./pullRequest/PullRequestsUnavailableState"; -import { RightPanelTabs, type PullRequestTabStatus } from "./RightPanelTabs"; +import { RightPanelTabs } from "./RightPanelTabs"; import { AgentsPanel } from "./AgentsPanel"; import { deriveAgentPanelModel, @@ -337,11 +338,6 @@ import { shouldShowThreadErrorBanner, ThreadErrorBanner, } from "./chat/ThreadErrorBanner"; -import { - resolveDisplayedThreadPr, - threadChangeRequestSnapshotsAtom, - useLinkedThreadPullRequest, -} from "./ThreadStatusIndicators"; import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { ComposerSurface } from "./chat/ComposerSurface"; import { @@ -380,6 +376,7 @@ import { shouldShowBranchMismatchBanner, shouldShowPlanFollowUpPrompt, shouldOpenProactivePullRequest, + shouldRetargetThreadPullRequestPanel, shouldOpenProactiveTurnDiff, shouldRenderPreviewMiniPlayer, getStartedThreadModelChangeBlockReason, @@ -1845,7 +1842,6 @@ export default function ChatView(props: ChatViewProps) { ); const activeThreadKey = activeThreadRef ? scopedThreadKey(activeThreadRef) : null; const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); - const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); const [timelineAnchor, setTimelineAnchor] = useState<{ readonly threadKey: string | null; readonly messageId: MessageId | null; @@ -1864,10 +1860,6 @@ export default function ChatView(props: ChatViewProps) { const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); - const refreshVcsStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); - const sidebarPrRefreshKeyRef = useRef(null); - const threadPrRelinkKeysRef = useRef(new Map()); - const threadPrRelinkWriteRef = useRef(Promise.resolve()); const activePreviewState = useThreadPreviewState(activeThreadRef); const activePreviewServerEpoch = activePreviewState.serverEpoch; const resolvePreviewRuntimeTabId = useMemo( @@ -3970,52 +3962,11 @@ export default function ChatView(props: ChatViewProps) { }, [activeProject, activeThreadRef], ); - // The thread's own change request, placed against the project it belongs to. Without a - // project there is nothing to resolve it against, so the caller falls back to the browser. - const persistedLinkedThreadPullRequest = isServerThread - ? (activeThreadShell?.linkedPullRequest ?? activeThread?.linkedPullRequest ?? null) - : (activeThread?.linkedPullRequest ?? null); - const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; - const persistedLinkedThreadPullRequestStatus = useLinkedThreadPullRequest( - activeThreadRef?.environmentId ?? null, - persistedLinkedThreadPullRequest, - ); - const replacementLinkedThreadPullRequest = useMemo(() => { - const detected = gitStatusQuery.data?.pr; - const threadBranch = activeThread?.branch; - const projectId = activeProject?.id; - if ( - persistedLinkedThreadPullRequest === null || - (persistedLinkedThreadPullRequestStatus?.pr.state !== "merged" && - persistedLinkedThreadPullRequestStatus?.pr.state !== "closed") || - gitStatusQuery.data?.refName !== threadBranch || - detected?.state !== "open" || - detected.headRef !== threadBranch || - projectId === undefined || - activeProjectRepository === null || - (persistedLinkedThreadPullRequest.projectId === projectId && - persistedLinkedThreadPullRequest.repository.toLowerCase() === - activeProjectRepository.toLowerCase() && - persistedLinkedThreadPullRequest.number === detected.number) - ) { - return null; - } - return { - projectId, - repository: activeProjectRepository, - number: detected.number, - url: detected.url, - }; - }, [ - activeProject?.id, - activeProjectRepository, - activeThread?.branch, - gitStatusQuery.data, - persistedLinkedThreadPullRequest, - persistedLinkedThreadPullRequestStatus?.pr.state, - ]); + // The shell carries server PR updates even while thread detail is still loading. + const activeThreadMetadata = activeThreadShell ?? activeThread; const linkedThreadPullRequest = - replacementLinkedThreadPullRequest ?? persistedLinkedThreadPullRequest; + activeThreadMetadata?.linkedPullRequest ?? activeThreadMetadata?.branchPullRequest ?? null; + const activeProjectRepository = activeProject?.repositoryIdentity?.displayName ?? null; const linkedThreadPullRequestKey = linkedThreadPullRequest ? JSON.stringify([ linkedThreadPullRequest.projectId, @@ -4023,68 +3974,10 @@ export default function ChatView(props: ChatViewProps) { linkedThreadPullRequest.number, ]) : null; - const threadRepository = linkedThreadPullRequest?.repository ?? activeProjectRepository; - const openThreadPullRequest = useCallback( - (number: number) => { - if (!supportsPullRequests || !activeThreadRef) { - return; - } - const projectId = linkedThreadPullRequest?.projectId ?? activeProject?.id; - const repository = linkedThreadPullRequest?.repository ?? activeProjectRepository; - if (projectId === undefined || repository === null) return; - useRightPanelStore.getState().openPullRequest(activeThreadRef, { - projectId, - repository, - number, - }); - }, - [ - activeProject, - activeProjectRepository, - activeThreadRef, - linkedThreadPullRequest, - supportsPullRequests, - ], - ); - useEffect(() => { - if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { - return; - } - if (replacementLinkedThreadPullRequest === null) { - threadPrRelinkKeysRef.current.delete(activeThreadKey); - return; - } - const relinkKey = `${replacementLinkedThreadPullRequest.projectId}:${replacementLinkedThreadPullRequest.repository}#${replacementLinkedThreadPullRequest.number}`; - if (threadPrRelinkKeysRef.current.get(activeThreadKey) === relinkKey) return; - threadPrRelinkKeysRef.current.set(activeThreadKey, relinkKey); - threadPrRelinkWriteRef.current = threadPrRelinkWriteRef.current.then(async () => { - if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; - const result = await updateThreadMetadata({ - environmentId: activeThreadRef.environmentId, - input: { - threadId: activeThreadRef.threadId, - linkedPullRequest: replacementLinkedThreadPullRequest, - }, - }); - if (threadPrRelinkKeysRef.current.get(activeThreadKey) !== relinkKey) return; - if (result._tag !== "Failure") return; - threadPrRelinkKeysRef.current.delete(activeThreadKey); - if (isAtomCommandInterrupted(result)) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to update the thread pull request", - description: chatActionErrorMessage(squashAtomCommandFailure(result)), - }), - ); - }); - }, [ - activeThreadKey, - activeThreadRef, - isServerThread, - replacementLinkedThreadPullRequest, - updateThreadMetadata, - ]); + const observedThreadPullRequestRef = useRef<{ + readonly threadKey: string; + readonly reference: ThreadLinkedPullRequest | null; + } | null>(null); const openProjectPullRequest = useCallback( (number: number) => { if ( @@ -4110,6 +4003,7 @@ export default function ChatView(props: ChatViewProps) { useEffect(() => { if (!isServerThread || activeThreadKey === null || activeThreadRef === null) { proactivePanelObservationRef.current = null; + observedThreadPullRequestRef.current = null; return; } const panels = useRightPanelStore.getState(); @@ -4125,20 +4019,24 @@ export default function ChatView(props: ChatViewProps) { userActionRevision, } = observation; const openSurface = selectActiveRightPanelSurface(panels.byThreadKey, activeThreadRef); + const previousPullRequest = observedThreadPullRequestRef.current; + observedThreadPullRequestRef.current = { + threadKey: activeThreadKey, + reference: linkedThreadPullRequest, + }; const followSelectedPullRequest = - replacementLinkedThreadPullRequest !== null && - openSurface?.kind === "pull-request" && - persistedLinkedThreadPullRequest !== null && - openSurface.projectId === persistedLinkedThreadPullRequest.projectId && - openSurface.repository.toLowerCase() === - persistedLinkedThreadPullRequest.repository.toLowerCase() && - openSurface.number === persistedLinkedThreadPullRequest.number; + previousPullRequest?.threadKey === activeThreadKey && + shouldRetargetThreadPullRequestPanel( + previousPullRequest.reference, + linkedThreadPullRequest, + openSurface, + ); // Following the selected linked PR does not open an unrelated panel, so it // remains available with proactive panels off. It still respects a later choice. - if (followSelectedPullRequest && replacementLinkedThreadPullRequest !== null) { + if (followSelectedPullRequest && linkedThreadPullRequest !== null) { panels.openProactive( activeThreadRef, - pullRequestSurface(replacementLinkedThreadPullRequest), + pullRequestSurface(linkedThreadPullRequest), userActionRevision, ); } @@ -4207,9 +4105,7 @@ export default function ChatView(props: ChatViewProps) { linkedThreadPullRequest, linkedThreadPullRequestKey, onDiffPanelOpen, - persistedLinkedThreadPullRequest, pullRequestsCapabilityKnown, - replacementLinkedThreadPullRequest, settings.proactivePanelsEnabled, shouldUseRightPanelSheet, supportsPullRequests, @@ -5223,50 +5119,6 @@ export default function ChatView(props: ChatViewProps) { resizeObserver.disconnect(); }; }, [composerOverlayElement, publishComposerOverlayHeight]); - const activeThreadPr = - replacementLinkedThreadPullRequest !== null - ? (gitStatusQuery.data?.pr ?? null) - : resolveDisplayedThreadPr({ - threadBranch: activeThread?.branch ?? null, - gitStatus: gitStatusQuery.data ?? null, - snapshot: activeThreadKey ? changeRequestSnapshotByKey.get(activeThreadKey) : undefined, - retainTerminalOnBranchMismatch: activeThread?.worktreePath === null, - linkedPullRequest: linkedThreadPullRequest, - linkedPullRequestStatus: persistedLinkedThreadPullRequestStatus, - }); - const handlePullRequestTabStatusChange = useCallback( - (status: Pick) => { - if ( - threadRepository?.toLowerCase() !== status.repository.toLowerCase() || - activeThreadPr?.number !== status.number || - activeThreadPr.state === status.state - ) { - sidebarPrRefreshKeyRef.current = null; - return; - } - const refreshKey = `${activeThreadKey}:vcs:${status.repository}#${status.number}:${status.state}`; - if (sidebarPrRefreshKeyRef.current === refreshKey) return; - sidebarPrRefreshKeyRef.current = refreshKey; - if (activeThreadRef === null || gitCwd === null) return; - void refreshVcsStatus({ - environmentId: activeThreadRef.environmentId, - input: { cwd: gitCwd }, - }).then(() => { - if (sidebarPrRefreshKeyRef.current === refreshKey) { - sidebarPrRefreshKeyRef.current = null; - } - }); - }, - [ - activeThreadKey, - activeThreadPr?.number, - activeThreadPr?.state, - activeThreadRef, - gitCwd, - refreshVcsStatus, - threadRepository, - ], - ); const openPanelPullRequestUrl = useOpenPanelPullRequestUrl(activeThreadRef); const activeThreadReferenceCopyTarget = useMemo( () => @@ -5276,15 +5128,8 @@ export default function ChatView(props: ChatViewProps) { threadId: activeThreadId, openPanelPullRequestUrl, linkedPullRequestUrl: linkedThreadPullRequest?.url ?? null, - detectedPullRequestUrl: activeThreadPr?.url ?? null, }), - [ - activeThreadId, - activeThreadPr?.url, - isServerThread, - linkedThreadPullRequest?.url, - openPanelPullRequestUrl, - ], + [activeThreadId, isServerThread, linkedThreadPullRequest?.url, openPanelPullRequestUrl], ); const copyActiveThreadReference = useCallback(() => { const target = activeThreadReferenceCopyTarget; @@ -5310,14 +5155,12 @@ export default function ChatView(props: ChatViewProps) { }, ); }, [activeThreadReferenceCopyTarget]); - // The right panel offers the thread's own change request, so it can only offer it once the - // branch has one; until then the picker says so rather than opening an empty panel. const addPullRequestSurface = useCallback(() => { - if (activeThreadPr === null) return; - openThreadPullRequest(activeThreadPr.number); - }, [activeThreadPr, openThreadPullRequest]); - const pullRequestSurfaceAvailable = - supportsPullRequests && activeThreadPr !== null && threadRepository !== null; + if (!supportsPullRequests || activeThreadRef === null || linkedThreadPullRequest === null) + return; + useRightPanelStore.getState().openPullRequest(activeThreadRef, linkedThreadPullRequest); + }, [activeThreadRef, linkedThreadPullRequest, supportsPullRequests]); + const pullRequestSurfaceAvailable = supportsPullRequests && linkedThreadPullRequest !== null; const supportsSettlement = serverConfig?.environment.capabilities.threadSettlement === true; const supportsSnooze = serverConfig?.environment.capabilities.threadSnooze === true; const supportsPinning = serverConfig?.environment.capabilities.threadPinning === true; @@ -7849,9 +7692,9 @@ export default function ChatView(props: ChatViewProps) { context={ isThreadOwnPullRequest( { - projectId: linkedThreadPullRequest?.projectId ?? activeProject?.id ?? null, - repository: threadRepository, - number: activeThreadPr?.number ?? null, + projectId: linkedThreadPullRequest?.projectId ?? null, + repository: linkedThreadPullRequest?.repository ?? null, + number: linkedThreadPullRequest?.number ?? null, }, { projectId: renderedRightPanelSurface.projectId, @@ -7863,9 +7706,6 @@ export default function ChatView(props: ChatViewProps) { : "page" } composerDraftTarget={composerDraftTarget} - {...(linkedThreadPullRequest === null - ? { onStateChange: handlePullRequestTabStatusChange } - : {})} /> ) : renderedRightPanelSurface?.kind === "agents" ? ( { const target = activeThreadReferenceCopyTarget; diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index fd1343c115c8..7ffb79e3809f 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -16,7 +16,6 @@ import { ChangeRequestStatusIcon, prStatusIndicator, PrStatusTooltipContent, - resolveThreadPr, terminalStatusFromRunningIds, ThreadStatusLabel, ThreadWorktreeIndicator, @@ -83,7 +82,6 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { isMacPlatform } from "../lib/utils"; import { readThreadShell, - useProject, useProjects, useThreadShells, useThreadShellsForProjectRefs, @@ -116,9 +114,7 @@ import { useDesktopUpdateState } from "../state/desktopUpdate"; import { useThreadActions } from "../hooks/useThreadActions"; import { projectEnvironment } from "../state/projects"; -import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; -import { vcsEnvironment } from "../state/vcs"; import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { buildThreadRouteParams, @@ -191,7 +187,6 @@ import { orderItemsByPreferredIds, shouldClearThreadSelectionOnMouseDown, sortProjectsForSidebar, - useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, ThreadStatusPill, @@ -314,7 +309,6 @@ function buildThreadJumpLabelMap(input: { interface SidebarThreadRowProps { thread: SidebarThreadSummary; - projectCwd: string | null; orderedProjectThreadKeys: readonly string[]; isActive: boolean; openPullRequestsInRightPanel: boolean; @@ -416,25 +410,6 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) : null; - // For grouped projects, the thread may belong to a different environment - // than the representative project. Look up the thread's own project cwd - // so git status (and thus PR detection) queries the correct path. - const threadProject = useProject( - useMemo( - () => scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd ?? props.projectCwd; - const gitStatus = useEnvironmentQuery( - leaseLiveStatus && thread.linkedPullRequest == null && thread.branch != null && gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); const isHighlighted = isActive || isSelected; const handleOpenDiscoveredPort = useCallback( (event: React.MouseEvent) => { @@ -470,28 +445,12 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP }, }); const linkedPullRequestStatus = useLinkedThreadPullRequest( - leaseLiveStatus ? thread.environmentId : null, - leaseLiveStatus ? thread.linkedPullRequest : null, - ); - const visibleGitStatus = useRetainedValue( - JSON.stringify([thread.environmentId, gitCwd]), - gitStatus.data, - ); - const visibleLinkedPullRequestStatus = useRetainedValue( - thread.linkedPullRequest === null - ? null - : JSON.stringify([thread.environmentId, thread.linkedPullRequest]), - linkedPullRequestStatus, - ); - const pr = - thread.linkedPullRequest == null - ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: visibleGitStatus }) - : (visibleLinkedPullRequestStatus?.pr ?? null); - const prStatus = prStatusIndicator( - pr, - visibleLinkedPullRequestStatus?.sourceControlProvider ?? - visibleGitStatus?.sourceControlProvider, + thread.environmentId, + thread.linkedPullRequest ?? thread.branchPullRequest, + leaseLiveStatus, ); + const pr = linkedPullRequestStatus?.pr ?? null; + const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const isConfirmingArchive = confirmingArchiveThreadKey === threadKey && !isThreadRunning; const threadMetaClassName = isConfirmingArchive @@ -940,7 +899,6 @@ interface SidebarProjectThreadListProps { showEmptyThreadState: boolean; shouldShowThreadPanel: boolean; isThreadListExpanded: boolean; - projectCwd: string; activeRouteThreadKey: string | null; openPullRequestsInRightPanel: boolean; threadJumpLabelByKey: ReadonlyMap; @@ -996,7 +954,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( showEmptyThreadState, shouldShowThreadPanel, isThreadListExpanded, - projectCwd, activeRouteThreadKey, openPullRequestsInRightPanel, threadJumpLabelByKey, @@ -1048,7 +1005,6 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( void; onUnpin: (threadRef: ScopedThreadRef) => void; onAcknowledgeWoke: (threadRef: ScopedThreadRef, visitedAt: string) => void; - changeRequestSnapshot: ThreadChangeRequestSnapshot | null; - onChangeRequestSnapshot: ( - threadKey: string, - snapshot: ThreadChangeRequestSnapshot | null, - ) => void; }) { const { isRenaming, - changeRequestSnapshot, - onChangeRequestSnapshot, onCancelRename, onCommitRename, onContextMenu, @@ -856,8 +843,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const gitCwd = thread.worktreePath ?? props.projectCwd; const linkedPullRequestStatus = useLinkedThreadPullRequest( - leaseLiveStatus ? thread.environmentId : null, - leaseLiveStatus ? thread.linkedPullRequest : null, + thread.environmentId, + thread.linkedPullRequest ?? thread.branchPullRequest, + leaseLiveStatus, ); const gitStatus = useEnvironmentQuery( leaseLiveStatus && (thread.branch != null || thread.worktreePath !== null) && gitCwd !== null @@ -871,15 +859,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { JSON.stringify([thread.environmentId, gitCwd]), gitStatus.data, ); - const retainTerminalOnBranchMismatch = thread.worktreePath === null; - const pr = resolveDisplayedThreadPr({ - threadBranch: thread.branch, - gitStatus: visibleGitStatus, - snapshot: changeRequestSnapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest: thread.linkedPullRequest, - linkedPullRequestStatus, - }); + const pr = linkedPullRequestStatus?.pr ?? null; // Same semantics as the legacy sidebar (never-visited counts as read): // switching sidebars must not light up every historical thread as unread. @@ -972,37 +952,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { activeThreadBranch: thread.branch, currentGitBranch: visibleGitStatus?.refName ?? null, }); - const prProvider = resolveDisplayedThreadPrProvider({ - threadBranch: thread.branch, - gitStatus: visibleGitStatus, - snapshot: changeRequestSnapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest: thread.linkedPullRequest, - linkedPullRequestStatus, - }); - const prStatus = prStatusIndicator(pr, prProvider); + const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); const settledPrHoverClass = pr ? settledPrHoverColorClass(pr.state, pr.isDraft) : undefined; - useEffect(() => { - const nextSnapshot = nextThreadChangeRequestSnapshot({ - threadBranch: thread.branch, - gitStatus: visibleGitStatus, - snapshot: changeRequestSnapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest: thread.linkedPullRequest, - linkedPullRequestStatus, - }); - if (nextSnapshot === undefined) return; - onChangeRequestSnapshot(threadKey, nextSnapshot); - }, [ - changeRequestSnapshot, - visibleGitStatus, - linkedPullRequestStatus, - onChangeRequestSnapshot, - retainTerminalOnBranchMismatch, - thread.branch, - thread.linkedPullRequest, - threadKey, - ]); const modelInstanceId = thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; const providerEntry = props.providerEntryByInstanceId.get(modelInstanceId) ?? null; @@ -2104,8 +2055,6 @@ export default function Sidebar() { // fresh clock whenever it recomputes. const [snoozeWakeTick, bumpSnoozeWakeTick] = useState(0); - const changeRequestSnapshotByKey = useAtomValue(threadChangeRequestSnapshotsAtom); - // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. // The selection lives in the persisted UI store next to the other sidebar @@ -4092,8 +4041,6 @@ export default function Sidebar() { onUnsnooze={attemptUnsnooze} onUnpin={attemptUnpin} onAcknowledgeWoke={acknowledgeWoke} - changeRequestSnapshot={changeRequestSnapshotByKey.get(threadKey) ?? null} - onChangeRequestSnapshot={setThreadChangeRequestSnapshot} /> ); }; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 21949a1d1808..7dd8c79c9b46 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,7 +1,5 @@ import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { AtomRegistry } from "effect/unstable/reactivity"; import { GitMergeIcon, GitPullRequestClosedIcon, @@ -11,15 +9,8 @@ import { import { ChangeRequestStatusIcon, - nextThreadChangeRequestSnapshot, prStatusIndicator, - resolveDisplayedThreadPr, - resolveDisplayedThreadPrProvider, - resolveThreadPr, settledPrHoverColorClass, - threadChangeRequestSnapshotsEqual, - threadChangeRequestSnapshotsAtom, - type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; @@ -57,25 +48,6 @@ function status(overrides: Partial = {}): VcsStatusResult { }; } -function mergedFeaturePr(): NonNullable { - return { - number: 42, - title: "Feature PR", - url: "https://github.com/pingdotgg/t3code/pull/42", - baseRef: "main", - headRef: "feature/current", - state: "merged", - }; -} - -function snapshotFor( - branch: string, - pr: NonNullable, - sourceControlProvider?: VcsStatusResult["sourceControlProvider"], -): ThreadChangeRequestSnapshot { - return { branch, pr, sourceControlProvider }; -} - function pullRequestSummary( state: PullRequestSummary["state"], updatedAt: string, @@ -117,503 +89,6 @@ describe("shared pull request state", () => { }); }); -describe("resolveThreadPr", () => { - it("keeps local-checkout PR indicators scoped to the stored thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "feature/other", - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "stack/base", - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("hides PR indicators when thread branch metadata is missing", () => { - expect( - resolveThreadPr({ - threadBranch: null, - gitStatus: status(), - }), - ).toBeNull(); - }); - - it("shows the PR when the live checkout matches the stored thread branch", () => { - const gitStatus = status(); - - expect( - resolveThreadPr({ - threadBranch: "feature/current", - gitStatus, - }), - ).toBe(gitStatus.pr); - }); -}); - -describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { - const featureBranch = "feature/current"; - const mergedPr = mergedFeaturePr(); - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const provider = { - kind: "github" as const, - name: "GitHub", - baseUrl: "https://github.com", - }; - - it("returns the live merged PR when the checkout matches the feature branch", () => { - const gitStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBe(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); - }); - - it("shows a linked pull request when the checkout has a different branch", () => { - const linkedPullRequestStatus = { - pr: mergedPr, - sourceControlProvider: provider, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(provider); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual({ - branch: "feature/other", - pr: mergedPr, - sourceControlProvider: provider, - linkedPullRequest, - }); - }); - - it("keeps a matching linked pull request snapshot while its status reloads", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toEqual(mergedPr); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeUndefined(); - }); - - it("clears an old snapshot when a different pull request is linked", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest: { ...linkedPullRequest, number: 41 }, - }; - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeNull(); - }); - - it("removes a linked pull request snapshot after the link is cleared", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("after caching a merged PR, resolves main status back to the cached feature PR", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); - - const mainStatus = status({ - refName: "main", - isDefaultRef: true, - pr: { - number: 99, - title: "Unrelated main PR", - url: "https://github.com/pingdotgg/t3code/pull/99", - baseRef: "main", - headRef: "main", - state: "open", - }, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); - }); - - it("never attaches a PR reported by main to the feature thread", () => { - const mainPr = { - number: 99, - title: "Unrelated main PR", - url: "https://github.com/pingdotgg/t3code/pull/99", - baseRef: "develop", - headRef: "main", - state: "merged" as const, - }; - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("does not show a cached open PR across a branch mismatch", () => { - const openSnapshot = snapshotFor(featureBranch, { - ...mergedPr, - state: "open", - title: "Still open", - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("retains a cached closed PR across a branch mismatch", () => { - const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; - const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: closedSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(closedPr); - }); - - it("does not retain or display a terminal PR when a worktree switches branches", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - const mismatchedStatus = status({ refName: "feature/other", pr: null }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeUndefined(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - }); - - it("retains a local terminal snapshot when thread metadata follows the new branch", () => { - const otherBranchSnapshot = snapshotFor("feature/other", mergedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: otherBranchSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("clears an open snapshot when a local thread moves to a branch with no PR", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("clears an open snapshot when a local checkout moves to a different branch", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("clears a retained snapshot when the thread branch is cleared", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - }); - - it("does not erase a terminal snapshot when VCS data is missing", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("retains a merged PR after a main checkout", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).not.toBeNull(); - expect(cached).not.toBeUndefined(); - - const mainStatus = status({ refName: "main", pr: null, isDefaultRef: true }); - const displayed = resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }); - expect(displayed?.state).toBe("merged"); - }); - - it("refreshes a cached snapshot when a pull request becomes ready", () => { - const readyPr = { ...mergedPr, state: "open" as const }; - const draftPr = { ...readyPr, isDraft: true }; - - expect( - threadChangeRequestSnapshotsEqual( - snapshotFor(featureBranch, draftPr), - snapshotFor(featureBranch, readyPr), - ), - ).toBe(false); - }); -}); - -describe("threadChangeRequestSnapshotsAtom", () => { - it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => - Effect.gen(function* () { - const registry = AtomRegistry.make(); - const threadKey = "environment-1:thread-1"; - const snapshot = snapshotFor("feature/current", mergedFeaturePr()); - - const unmount = registry.mount(threadChangeRequestSnapshotsAtom); - registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); - unmount(); - - yield* Effect.yieldNow; - - const remount = registry.mount(threadChangeRequestSnapshotsAtom); - expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); - - remount(); - registry.dispose(); - }), - ); -}); - describe("prStatusIndicator", () => { it("formats PR tooltips with number, uppercase status, and title", () => { expect(prStatusIndicator(status().pr, undefined)).toMatchObject({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index e7b24f80cb71..768154419927 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,8 +1,4 @@ -import { - scopeProjectRef, - scopedThreadKey, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { pullRequestDetailToVcsStatus } from "@t3tools/client-runtime/state/pull-requests"; import { type EnvironmentId, @@ -10,17 +6,13 @@ import { type ThreadLinkedPullRequest, type VcsStatusResult, } from "@t3tools/contracts"; -import { Atom } from "effect/unstable/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { appAtomRegistry } from "../rpc/atomRegistry"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { linkedPullRequestDetailAtom, useSharedPullRequestSummary } from "../state/pullRequests"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { vcsEnvironment } from "../state/vcs"; import { useUiStateStore } from "../uiStateStore"; import { resolveChangeRequestPresentation } from "../sourceControlPresentation"; import { resolveThreadStatusPill, type ThreadStatusPill } from "./Sidebar.logic"; @@ -51,12 +43,14 @@ export interface LinkedThreadPullRequestStatus { readonly sourceControlProvider: NonNullable; } +/** Keep cached summaries visible when an offscreen row stops live queries. */ export function useLinkedThreadPullRequest( environmentId: EnvironmentId | null, linkedPullRequest: ThreadLinkedPullRequest | null | undefined, + enabled = true, ): LinkedThreadPullRequestStatus | null { const queried = useEnvironmentQuery( - environmentId === null || linkedPullRequest == null + !enabled || environmentId === null || linkedPullRequest == null ? null : linkedPullRequestDetailAtom({ environmentId, @@ -178,271 +172,6 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } ); } -export function resolveThreadPr(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): ThreadPr | null { - const { threadBranch, gitStatus } = input; - if (gitStatus === null) { - return null; - } - - if (threadBranch === null || gitStatus.refName !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - -/** - * Parent-held PR snapshot for Sidebar V2. Rows remount when settlement - * partitions move them, so terminal PR metadata must live above the row. - */ -export interface ThreadChangeRequestSnapshot { - readonly branch: string; - readonly pr: NonNullable; - readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest; -} - -export const threadChangeRequestSnapshotsAtom = Atom.make< - ReadonlyMap ->(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); - -function isTerminalChangeRequestState( - state: NonNullable["state"], -): state is "merged" | "closed" { - return state === "merged" || state === "closed"; -} - -function sourceControlProvidersEqual( - left: VcsStatusResult["sourceControlProvider"] | undefined, - right: VcsStatusResult["sourceControlProvider"] | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return left == null && right == null; - return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; -} - -function linkedPullRequestsEqual( - left: ThreadLinkedPullRequest | null | undefined, - right: ThreadLinkedPullRequest | null | undefined, -): boolean { - if (left == null || right == null) return left == null && right == null; - return ( - left.projectId === right.projectId && - left.repository === right.repository && - left.number === right.number && - left.url === right.url - ); -} - -export function threadChangeRequestSnapshotsEqual( - left: ThreadChangeRequestSnapshot, - right: ThreadChangeRequestSnapshot, -): boolean { - return ( - left.branch === right.branch && - left.pr.number === right.pr.number && - left.pr.title === right.pr.title && - left.pr.url === right.pr.url && - left.pr.baseRef === right.pr.baseRef && - left.pr.headRef === right.pr.headRef && - left.pr.state === right.pr.state && - left.pr.isDraft === right.pr.isDraft && - (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && - sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && - linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) - ); -} - -export function setThreadChangeRequestSnapshot( - threadKey: string, - snapshot: ThreadChangeRequestSnapshot | null, -): void { - appAtomRegistry.modify(threadChangeRequestSnapshotsAtom, (current) => { - const existing = current.get(threadKey); - if (snapshot === null) { - if (existing === undefined) return [false, current]; - const next = new Map(current); - next.delete(threadKey); - return [true, next]; - } - if (existing !== undefined && threadChangeRequestSnapshotsEqual(existing, snapshot)) { - return [false, current]; - } - const next = new Map(current); - next.set(threadKey, snapshot); - return [true, next]; - }); -} - -/** - * Authoritative snapshot update from live VCS status. - * - `undefined`: missing status, or a local checkout retaining a terminal PR — leave the map alone - * - `null`: no PR (without a retained terminal snapshot), a cleared branch, or a mismatch without a terminal PR — clear - * - snapshot: matching branch reports a PR — store/replace - */ -export function nextThreadChangeRequestSnapshot(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): ThreadChangeRequestSnapshot | null | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { - return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? undefined - : null; - } - return { - branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, - pr: linkedPullRequestStatus.pr, - sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, - linkedPullRequest, - }; - } - if (gitStatus === null) { - return snapshot?.linkedPullRequest === undefined ? undefined : null; - } - if (threadBranch === null) { - return null; - } - if (gitStatus.refName !== threadBranch) { - return retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ? undefined - : null; - } - if (gitStatus.pr == null) { - if ( - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return undefined; - } - return null; - } - return { - branch: threadBranch, - pr: gitStatus.pr, - sourceControlProvider: gitStatus.sourceControlProvider, - }; -} - -/** - * Live PR when the checkout matches the thread branch; otherwise, for local - * checkouts only, a cached merged/closed PR for the thread. Local thread - * metadata follows the shared checkout, so the cached branch intentionally - * survives that metadata changing to the newly checked-out branch. Open PRs - * are never retained — their state can still change. - */ -export function resolveDisplayedThreadPr(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): ThreadPr | null { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.pr ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? (snapshot?.pr ?? null) - : null) - ); - } - if ( - threadBranch !== null && - gitStatus !== null && - gitStatus.refName === threadBranch && - gitStatus.pr != null - ) { - return gitStatus.pr; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.pr; - } - - return null; -} - -export function resolveDisplayedThreadPrProvider(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): VcsStatusResult["sourceControlProvider"] | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.sourceControlProvider ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? snapshot?.sourceControlProvider - : undefined) - ); - } - if ( - threadBranch !== null && - gitStatus !== null && - gitStatus.refName === threadBranch && - gitStatus.pr != null - ) { - return gitStatus.sourceControlProvider; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.sourceControlProvider; - } - - return undefined; -} - export function terminalStatusFromRunningIds( runningTerminalIds: ReadonlyArray, ): TerminalStatusIndicator | null { @@ -551,36 +280,12 @@ export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummar const lastVisitedAt = useUiStateStore( (state) => state.threadLastVisitedAtById[scopedThreadKey(threadRef)], ); - const threadProject = useProject( - useMemo( - () => scopeProjectRef(thread.environmentId, thread.projectId), - [thread.environmentId, thread.projectId], - ), - ); - const threadProjectCwd = threadProject?.workspaceRoot ?? null; - const gitCwd = thread.worktreePath ?? threadProjectCwd; - const linkedPullRequest = useLinkedThreadPullRequest( + const pullRequest = useLinkedThreadPullRequest( thread.environmentId, - thread.linkedPullRequest, - ); - const gitStatus = useEnvironmentQuery( - thread.linkedPullRequest == null && - (thread.branch != null || thread.worktreePath !== null) && - gitCwd !== null - ? vcsEnvironment.status({ - environmentId: thread.environmentId, - input: { cwd: gitCwd }, - }) - : null, - ); - const pr = - thread.linkedPullRequest == null - ? resolveThreadPr({ threadBranch: thread.branch, gitStatus: gitStatus.data }) - : (linkedPullRequest?.pr ?? null); - const prStatus = prStatusIndicator( - pr, - linkedPullRequest?.sourceControlProvider ?? gitStatus.data?.sourceControlProvider, + thread.linkedPullRequest ?? thread.branchPullRequest, ); + const pr = pullRequest?.pr ?? null; + const prStatus = prStatusIndicator(pr, pullRequest?.sourceControlProvider); const threadStatus = resolveThreadStatusPill({ thread: { ...thread, diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 227d13d18bb7..f8514f83dcdf 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -8,7 +8,6 @@ import { type PullRequestListEntry, type PullRequestUpdateMethod, type PullRequestRef, - type PullRequestState, resolveEnvironmentMachineKind, type ScopedThreadRef, } from "@t3tools/contracts"; @@ -454,7 +453,6 @@ export function PullRequestDetailPanel({ refreshToken: forcedRefreshToken = 0, onActed, onClose, - onStateChange, context = "page", composerDraftTarget, }: { @@ -482,8 +480,6 @@ export function PullRequestDetailPanel({ onActed?: () => void; /** Page-owned detail columns use this to clear the selected pull request. */ onClose?: () => void; - /** Keeps surrounding inferred thread state in step with refreshed host state. */ - onStateChange?: (status: { repository: string; number: number; state: PullRequestState }) => void; /** * Beside a thread, the checkout affordance disappears: the panel is showing that thread's * own pull request, so the branch is already under the reader's feet — and checking it out @@ -714,14 +710,6 @@ export function PullRequestDetailPanel({ } activityRevision.current = next; }, [activityQuery.refresh, coreDetail, tabScopeKey]); - useLayoutEffect(() => { - if (!resolvedCoreDetail) return; - onStateChange?.({ - repository: resolvedCoreDetail.repository, - number: resolvedCoreDetail.number, - state: resolvedCoreDetail.state, - }); - }, [onStateChange, resolvedCoreDetail]); // Reuse activity and diff until core detail reports a changed revision. Keyed by // the pull request rather than by the panel, because this one panel shows a different pull // request every time it is opened. diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index e6c89c0343e7..0a2e7aebad76 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -49,8 +49,13 @@ in the warning. Changing a rule does not reopen already settled threads. ## Link a pull request +The server finds the PR for each unsettled thread's saved branch, even when your +apps are closed. Settled threads keep their saved links. Update the server if +automatic branch links do not appear. + On web and desktop, right-click a pull request link in a thread and choose -**Link to thread**. Use **Unlink from thread** on the same link to remove it. +**Link to thread** to select a different PR. Use **Unlink from thread** on the +same link to return to the branch PR, if one exists. The linked pull request participates in automatic settlement. ## Find and reference work diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 1f52eccc94cd..59b3cb0551e4 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -306,50 +306,65 @@ describe("applyThreadDetailEvent", () => { } }); - it("sets and clears a linked pull request", () => { - const linkedPullRequest = { - projectId: ProjectId.make("project-1"), - repository: "pingdotgg/t3code", - number: 42, - url: "https://github.com/pingdotgg/t3code/pull/42", - }; - const linked = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: "2026-04-01T05:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - linkedPullRequest, - updatedAt: "2026-04-01T05:00:00.000Z", - }, - }); + it.each(["linkedPullRequest", "branchPullRequest"] as const)( + "sets and clears %s without changing the other link", + (field) => { + const linkedPullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const otherField = + field === "linkedPullRequest" ? "branchPullRequest" : "linkedPullRequest"; + const otherPullRequest = { + ...linkedPullRequest, + number: 43, + url: "https://github.com/pingdotgg/t3code/pull/43", + }; + const linked = applyThreadDetailEvent( + { ...baseThread, [otherField]: otherPullRequest }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + [field]: linkedPullRequest, + updatedAt: "2026-04-01T05:00:00.000Z", + }, + }, + ); - expect(linked.kind).toBe("updated"); - if (linked.kind !== "updated") return; - expect(linked.thread.linkedPullRequest).toEqual(linkedPullRequest); + expect(linked.kind).toBe("updated"); + if (linked.kind !== "updated") return; + expect(linked.thread[field]).toEqual(linkedPullRequest); + expect(linked.thread[otherField]).toEqual(otherPullRequest); - const cleared = applyThreadDetailEvent(linked.thread, { - ...baseEventFields, - sequence: 6, - occurredAt: "2026-04-01T06:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - linkedPullRequest: null, - updatedAt: "2026-04-01T06:00:00.000Z", - }, - }); + const cleared = applyThreadDetailEvent(linked.thread, { + ...baseEventFields, + sequence: 6, + occurredAt: "2026-04-01T06:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + [field]: null, + updatedAt: "2026-04-01T06:00:00.000Z", + }, + }); - expect(cleared.kind).toBe("updated"); - if (cleared.kind === "updated") { - expect(cleared.thread.linkedPullRequest).toBeNull(); - } - }); + expect(cleared.kind).toBe("updated"); + if (cleared.kind === "updated") { + expect(cleared.thread[field]).toBeNull(); + expect(cleared.thread[otherField]).toEqual(otherPullRequest); + } + }, + ); }); describe("thread.message-sent", () => { diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index c237856f90b1..09272cd065cf 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -97,6 +97,7 @@ export function applyThreadDetailEvent( interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + branchPullRequest: null, latestTurn: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -240,6 +241,9 @@ export function applyThreadDetailEvent( ...(event.payload.linkedPullRequest !== undefined ? { linkedPullRequest: event.payload.linkedPullRequest } : {}), + ...(event.payload.branchPullRequest !== undefined + ? { branchPullRequest: event.payload.branchPullRequest } + : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 11c21deecddb..61dbdb9a5512 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Schema from "effect/Schema"; +import { CommandId, ProjectId, ThreadId } from "./baseSchemas.ts"; import { DEFAULT_PROVIDER_INTERACTION_MODE, @@ -837,6 +838,47 @@ it.effect("accepts an internal title regeneration completion", () => }), ); +it.effect("accepts pull request synchronization only as an internal command", () => + Effect.gen(function* () { + const pullRequest = { + projectId: ProjectId.make("project-1"), + repository: "pingdotgg/t3code", + number: 42, + url: "https://github.com/pingdotgg/t3code/pull/42", + }; + const command = { + type: "thread.pull-request.sync" as const, + commandId: CommandId.make("cmd-pull-request-sync"), + threadId: ThreadId.make("thread-1"), + projectId: pullRequest.projectId, + snapshotSequence: 12, + expected: { + workspaceRoot: "/workspace/project", + branch: "feature", + worktreePath: null, + linkedPullRequest: null, + branchPullRequest: null, + }, + branchPullRequest: pullRequest, + linkedPullRequest: pullRequest, + }; + + assert.deepStrictEqual(yield* decodeOrchestrationCommand(command), command); + assert.ok(yield* decodeClientOrchestrationCommand(command).pipe(Effect.flip)); + + const cleared = { ...command, branchPullRequest: null }; + assert.deepStrictEqual(yield* decodeOrchestrationCommand(cleared), cleared); + + const metadata = yield* decodeClientOrchestrationCommand({ + type: "thread.meta.update", + commandId: "cmd-forged-branch-pull-request", + threadId: "thread-1", + branchPullRequest: pullRequest, + }); + assert.isFalse("branchPullRequest" in metadata); + }), +); + it.effect("rejects an explicit title combined with title regeneration", () => Effect.gen(function* () { const result = yield* Effect.exit( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 92e9fe01dd42..47501e323441 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -494,6 +494,7 @@ export const OrchestrationThread = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -572,6 +573,7 @@ export const OrchestrationThreadShell = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -1183,6 +1185,23 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ title: Schema.optional(TrimmedNonEmptyString), }); +const ThreadPullRequestSyncCommand = Schema.Struct({ + type: Schema.Literal("thread.pull-request.sync"), + commandId: CommandId, + threadId: ThreadId, + projectId: ProjectId, + snapshotSequence: NonNegativeInt, + expected: Schema.Struct({ + workspaceRoot: TrimmedNonEmptyString, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + linkedPullRequest: Schema.NullOr(ThreadLinkedPullRequest), + branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), + }), + branchPullRequest: Schema.NullOr(ThreadLinkedPullRequest), + linkedPullRequest: Schema.optional(ThreadLinkedPullRequest), +}); + const InternalOrchestrationCommand = Schema.Union([ ThreadAutoSettleCommand, ThreadSessionSetCommand, @@ -1194,6 +1213,7 @@ const InternalOrchestrationCommand = Schema.Union([ ThreadActivityAppendCommand, ThreadRevertCompleteCommand, ThreadTitleRegenerationCompleteCommand, + ThreadPullRequestSyncCommand, ]); export type InternalOrchestrationCommand = typeof InternalOrchestrationCommand.Type; @@ -1367,6 +1387,7 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), linkedPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), + branchPullRequest: Schema.optional(Schema.NullOr(ThreadLinkedPullRequest)), updatedAt: IsoDateTime, }); diff --git a/packages/shared/src/threadReference.test.ts b/packages/shared/src/threadReference.test.ts index 60600c7e1465..1b975a8defe9 100644 --- a/packages/shared/src/threadReference.test.ts +++ b/packages/shared/src/threadReference.test.ts @@ -9,18 +9,16 @@ describe("resolveThreadReferenceCopyTarget", () => { threadId: "thread-1", openPanelPullRequestUrl: null, linkedPullRequestUrl: "https://github.com/t3/pr/12", - detectedPullRequestUrl: "https://github.com/t3/pr/13", }), ).toBeNull(); }); - it("prefers the open panel pull request over linked and detected pull requests", () => { + it("prefers the open panel pull request over the thread pull request", () => { expect( resolveThreadReferenceCopyTarget({ threadId: "thread-1", openPanelPullRequestUrl: "https://github.com/t3/pr/14", linkedPullRequestUrl: "https://github.com/t3/pr/12", - detectedPullRequestUrl: "https://github.com/t3/pr/13", }), ).toMatchObject({ kind: "pull-request", @@ -29,12 +27,11 @@ describe("resolveThreadReferenceCopyTarget", () => { }); }); - it("prefers a durable linked pull request", () => { + it("uses the thread pull request when no panel is open", () => { expect( resolveThreadReferenceCopyTarget({ threadId: "thread-1", linkedPullRequestUrl: "https://github.com/t3/pr/12", - detectedPullRequestUrl: "https://github.com/t3/pr/13", }), ).toMatchObject({ kind: "pull-request", @@ -43,18 +40,6 @@ describe("resolveThreadReferenceCopyTarget", () => { }); }); - it("uses a pull request detected from the active branch", () => { - expect( - resolveThreadReferenceCopyTarget({ - threadId: "thread-1", - detectedPullRequestUrl: "https://github.com/t3/pr/13", - }), - ).toMatchObject({ - kind: "pull-request", - value: "https://github.com/t3/pr/13", - }); - }); - it("falls back to the thread ID", () => { expect(resolveThreadReferenceCopyTarget({ threadId: "thread-1" })).toEqual({ kind: "thread", diff --git a/packages/shared/src/threadReference.ts b/packages/shared/src/threadReference.ts index 20eac6fba039..ce53078862ac 100644 --- a/packages/shared/src/threadReference.ts +++ b/packages/shared/src/threadReference.ts @@ -11,11 +11,9 @@ export function resolveThreadReferenceCopyTarget(input: { /** Undefined means no PR panel; null means its URL is not available yet. */ readonly openPanelPullRequestUrl?: string | null | undefined; readonly linkedPullRequestUrl?: string | null; - readonly detectedPullRequestUrl?: string | null; }): ThreadReferenceCopyTarget | null { if (input.openPanelPullRequestUrl === null) return null; - const pullRequestUrl = - input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl ?? input.detectedPullRequestUrl; + const pullRequestUrl = input.openPanelPullRequestUrl ?? input.linkedPullRequestUrl; return pullRequestUrl ? { kind: "pull-request", From 72cb638a8a6656d0b89899d9d62afaed0c2f7e5f Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Sun, 6 Sep 2026 21:33:27 +0530 Subject: [PATCH 222/320] fix(web): show Tux icon for WSL environments (#8511) --- .../components/EnvironmentMachineSymbol.tsx | 2 ++ .../ServerEnvironmentMachine.test.ts | 20 +++++++++++ .../environment/ServerEnvironmentMachine.ts | 9 ++++- .../src/components/EnvironmentMachineIcon.tsx | 3 ++ apps/web/src/components/Icons.tsx | 7 +++- apps/web/src/components/LegacySidebar.tsx | 34 ++++++++++++------- apps/web/src/connection/desktopLocal.test.ts | 13 +++++++ apps/web/src/connection/desktopLocal.ts | 4 +++ apps/web/src/sidebarProjectGrouping.ts | 14 +++++--- packages/contracts/src/environment.ts | 3 +- packages/contracts/src/settings.test.ts | 4 +++ 11 files changed, 94 insertions(+), 19 deletions(-) diff --git a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx index 46fbbd814fdf..4471fdea4fd2 100644 --- a/apps/mobile/src/components/EnvironmentMachineSymbol.tsx +++ b/apps/mobile/src/components/EnvironmentMachineSymbol.tsx @@ -6,6 +6,7 @@ import { SymbolView } from "./AppSymbol"; const SYMBOL_BY_KIND: Record = { server: "server.rack", cloud: "cloud", + linux: "terminal", desktop: "desktopcomputer", laptop: "laptopcomputer", "mac-mini": "macmini", @@ -15,6 +16,7 @@ const SYMBOL_BY_KIND: Record = { export const ENVIRONMENT_MACHINE_KIND_LABELS: Record = { server: "Server", cloud: "Cloud VM", + linux: "Linux/WSL", desktop: "Desktop", laptop: "Laptop", "mac-mini": "Mac mini", diff --git a/apps/server/src/environment/ServerEnvironmentMachine.test.ts b/apps/server/src/environment/ServerEnvironmentMachine.test.ts index c4318e7a9d7b..2ae0989227de 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.test.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.test.ts @@ -202,6 +202,26 @@ describe("detectServerEnvironmentMachineKind", () => { }), ); + it.effect("recognizes WSL before its Hyper-V DMI identity", () => + Effect.gen(function* () { + const result = yield* detectServerEnvironmentMachineKind().pipe( + Effect.provide( + withPlatform( + "linux", + dmiFileSystem({ + osrelease: "5.15.153.1-microsoft-standard-WSL2\n", + chassis_type: "3\n", + sys_vendor: "Microsoft Corporation\n", + product_name: "Virtual Machine\n", + }), + ), + ), + ); + + expect(result).toBe("linux"); + }), + ); + it.effect("returns null on Linux without DMI (containers, ARM boards)", () => Effect.gen(function* () { const result = yield* detectServerEnvironmentMachineKind().pipe( diff --git a/apps/server/src/environment/ServerEnvironmentMachine.ts b/apps/server/src/environment/ServerEnvironmentMachine.ts index e23342d12c09..9d11a1ef59fd 100644 --- a/apps/server/src/environment/ServerEnvironmentMachine.ts +++ b/apps/server/src/environment/ServerEnvironmentMachine.ts @@ -12,6 +12,7 @@ import * as ProcessRunner from "../processRunner.ts"; */ const DMI_ROOT = "/sys/class/dmi/id"; +const KERNEL_RELEASE_PATH = "/proc/sys/kernel/osrelease"; // SMBIOS 3.x System Enclosure types (table 17). Codes that describe a shape // rather than a machine (docking stations, blades enclosures, IoT gateways) @@ -144,11 +145,17 @@ const detectDarwinMachineKind = Effect.fn("detectDarwinMachineKind")(function* ( }); const detectLinuxMachineKind = Effect.fn("detectLinuxMachineKind")(function* () { - const [chassisType, sysVendor, productName] = yield* Effect.all([ + const [kernelRelease, chassisType, sysVendor, productName] = yield* Effect.all([ + readOptionalFile(KERNEL_RELEASE_PATH), readOptionalFile(`${DMI_ROOT}/chassis_type`), readOptionalFile(`${DMI_ROOT}/sys_vendor`), readOptionalFile(`${DMI_ROOT}/product_name`), ]); + // WSL exposes Microsoft in its kernel release on both WSL 1 and WSL 2. + // Check it before DMI because WSL 2 presents as a Hyper-V VM. + if (kernelRelease?.toLowerCase().includes("microsoft")) { + return "linux"; + } return machineKindFromDmi({ chassisType, sysVendor, productName }); }); diff --git a/apps/web/src/components/EnvironmentMachineIcon.tsx b/apps/web/src/components/EnvironmentMachineIcon.tsx index b42bf41266c9..2a5b0c0eaa89 100644 --- a/apps/web/src/components/EnvironmentMachineIcon.tsx +++ b/apps/web/src/components/EnvironmentMachineIcon.tsx @@ -1,6 +1,7 @@ import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { CloudIcon, LaptopIcon, MonitorIcon, ServerIcon, type LucideProps } from "lucide-react"; import type { FunctionComponent, SVGProps } from "react"; +import { LinuxIcon } from "./Icons"; // Lucide has no Apple desktops, so these two are drawn to its grammar (24 // unit grid, 2 unit stroke, round joins) and share its prop surface so callers @@ -45,6 +46,7 @@ function MacStudioIcon(props: SVGProps) { const ICON_BY_KIND: Record> = { server: ServerIcon, cloud: CloudIcon, + linux: LinuxIcon, desktop: MonitorIcon, laptop: LaptopIcon, "mac-mini": MacMiniIcon, @@ -54,6 +56,7 @@ const ICON_BY_KIND: Record = { server: "Server", cloud: "Cloud VM", + linux: "Linux/WSL", desktop: "Desktop", laptop: "Laptop", "mac-mini": "Mac mini", diff --git a/apps/web/src/components/Icons.tsx b/apps/web/src/components/Icons.tsx index edb41868879e..199d0ba834d0 100644 --- a/apps/web/src/components/Icons.tsx +++ b/apps/web/src/components/Icons.tsx @@ -1,8 +1,13 @@ import React, { type SVGProps, useId } from "react"; import { cn } from "~/lib/utils"; - export type Icon = React.FC>; +export const LinuxIcon: Icon = ({ className, ...props }) => ( + + + +); + export const GitHubIcon: Icon = (props) => ( ( (settings) => settings.sidebarThreadSortOrder, ); @@ -2351,15 +2354,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ? "Local sandbox project" : "Remote project" } - className="pointer-events-none absolute top-1 right-1.5 inline-flex size-5 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" + className="pointer-events-none absolute top-1/2 right-1.5 inline-flex size-5 -translate-y-1/2 items-center justify-center rounded-md text-icon-muted transition-opacity duration-150 max-sm:right-7 group-hover/project-header:opacity-0 group-focus-within/project-header:opacity-0 max-sm:group-hover/project-header:opacity-100 max-sm:group-focus-within/project-header:opacity-100" /> } > - {project.allRemoteMembersAreDesktopLocal ? ( - - ) : ( - - )} + {project.allRemoteMembersAreDesktopLocal @@ -3132,6 +3131,15 @@ export default function LegacySidebar() { ), [environments], ); + const wslEnvironmentIds = useMemo( + () => + new Set( + environments + .filter((environment) => isWslConnectionTarget(environment.entry.target)) + .map((environment) => environment.environmentId), + ), + [environments], + ); const orderedProjects = useMemo(() => { return orderItemsByPreferredIds({ items: projects, @@ -3172,10 +3180,12 @@ export default function LegacySidebar() { primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), + isWslEnvironment: (environmentId) => wslEnvironmentIds.has(environmentId), }); }, [ environmentLabelById, desktopLocalEnvironmentIds, + wslEnvironmentIds, orderedProjects, projectGroupingSettings, primaryEnvironmentId, diff --git a/apps/web/src/connection/desktopLocal.test.ts b/apps/web/src/connection/desktopLocal.test.ts index adc101ed61b5..0a1f94997704 100644 --- a/apps/web/src/connection/desktopLocal.test.ts +++ b/apps/web/src/connection/desktopLocal.test.ts @@ -10,6 +10,7 @@ import { desktopLocalBackendId, desktopLocalConnectionId, isDesktopLocalConnectionTarget, + isWslConnectionTarget, } from "./desktopLocal"; describe("desktop local connection identity", () => { @@ -22,6 +23,18 @@ describe("desktop local connection identity", () => { expect(isDesktopLocalConnectionTarget(target)).toBe(true); expect(desktopLocalBackendId(target)).toBe("wsl:Ubuntu"); + expect(isWslConnectionTarget(target)).toBe(true); + }); + + it("does not infer WSL for another desktop-local backend", () => { + const target = new BearerConnectionTarget({ + connectionId: desktopLocalConnectionId("native-linux"), + environmentId: EnvironmentId.make("environment-native-linux"), + label: "Linux", + }); + + expect(isDesktopLocalConnectionTarget(target)).toBe(true); + expect(isWslConnectionTarget(target)).toBe(false); }); it("does not classify the primary environment as desktop-local", () => { diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index d27d20e5b317..fd04b1c1bc2a 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -38,6 +38,10 @@ export function desktopLocalBackendId(target: ConnectionTarget): string | null { : null; } +export function isWslConnectionTarget(target: ConnectionTarget): boolean { + return desktopLocalBackendId(target)?.startsWith("wsl:") === true; +} + export type DesktopSecondaryBootstrapsRead = | { readonly _tag: "Success"; diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 8cf3c5665aca..3489ae57bf5d 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -15,11 +15,12 @@ export interface SidebarProjectSnapshot extends Project { groupedProjectCount: number; environmentPresence: EnvironmentPresence; // True iff every non-primary member of this group lives in a - // desktopLocal env (today: the WSL backend). The sidebar uses this + // desktop-local environment. The sidebar uses this // to differentiate "lives on this machine but in a sandbox" from // "lives on a real remote" so the project header can pick a - // container icon instead of the generic cloud icon. + // local-device treatment instead of the generic remote treatment. allRemoteMembersAreDesktopLocal: boolean; + allRemoteMembersAreWsl: boolean; memberProjects: readonly SidebarProjectGroupMember[]; memberProjectRefs: readonly ScopedProjectRef[]; remoteEnvironmentLabels: readonly string[]; @@ -55,11 +56,12 @@ export function buildSidebarProjectSnapshots(input: { settings: ProjectGroupingSettings; primaryEnvironmentId: EnvironmentId | null; resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; - // Returns true when an env id maps to a desktopLocal saved-env - // record (today: the WSL backend). Defaults to "false for every + // Returns true when an env id maps to a desktop-local saved-env + // record. Defaults to "false for every // env" so callers that don't care about the distinction get the // legacy behavior. isDesktopLocalEnvironment?: (environmentId: EnvironmentId) => boolean; + isWslEnvironment?: (environmentId: EnvironmentId) => boolean; }): SidebarProjectSnapshot[] { return buildProjectGroups({ projects: input.projects, @@ -95,9 +97,12 @@ export function buildSidebarProjectSnapshots(input: { .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); const isDesktopLocal = input.isDesktopLocalEnvironment ?? (() => false); + const isWsl = input.isWslEnvironment ?? (() => false); const allRemoteMembersAreDesktopLocal = remoteMembers.length > 0 && remoteMembers.every((member) => isDesktopLocal(member.environmentId)); + const allRemoteMembersAreWsl = + remoteMembers.length > 0 && remoteMembers.every((member) => isWsl(member.environmentId)); return { ...representative, @@ -107,6 +112,7 @@ export function buildSidebarProjectSnapshots(input: { environmentPresence: hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", allRemoteMembersAreDesktopLocal, + allRemoteMembersAreWsl, memberProjects: members, memberProjectRefs: group.memberProjectRefs, remoteEnvironmentLabels, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6edcff005d86..6b815591ff2b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -21,13 +21,14 @@ export const ExecutionEnvironmentPlatformArch = Schema.Literals(["arm64", "x64", export type ExecutionEnvironmentPlatformArch = typeof ExecutionEnvironmentPlatformArch.Type; /** - * The curated set of machine shapes an environment can wear as its icon. + * The curated set of machine shapes and OS identities an environment can wear as its icon. * Servers detect one from the hardware they run on (`platform.machine`), and * the `environmentIcon` server setting lets a user pick one instead. */ export const ENVIRONMENT_MACHINE_KINDS = [ "server", "cloud", + "linux", "desktop", "laptop", "mac-mini", diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 7c497bacfa89..dc4da6dde5d6 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -625,6 +625,7 @@ describe("ServerSettings environment icon", () => { it("keeps a kind this build knows", () => { expect(decodeServerSettings({ environmentIcon: "mac-mini" }).environmentIcon).toBe("mac-mini"); + expect(decodeServerSettings({ environmentIcon: "linux" }).environmentIcon).toBe("linux"); }); it("decodes a kind from a newer server as null instead of failing the snapshot", () => { @@ -634,5 +635,8 @@ describe("ServerSettings environment icon", () => { it("round-trips through encode", () => { const settings = decodeServerSettings({ environmentIcon: "laptop" }); expect(encodeServerSettings(settings).environmentIcon).toBe("laptop"); + + const linuxSettings = decodeServerSettings({ environmentIcon: "linux" }); + expect(encodeServerSettings(linuxSettings).environmentIcon).toBe("linux"); }); }); From 64fafbdfcf91d3c44b533f5b8febbe8d6b19d482 Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Sun, 6 Sep 2026 13:09:26 -0400 Subject: [PATCH 223/320] perf(web): speed up folder menu sorting (#10190) --- apps/web/src/components/files/filePath.test.ts | 11 +++++++++-- apps/web/src/components/files/filePath.ts | 4 +++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/files/filePath.test.ts b/apps/web/src/components/files/filePath.test.ts index 3018aa91a776..b501562fee00 100644 --- a/apps/web/src/components/files/filePath.test.ts +++ b/apps/web/src/components/files/filePath.test.ts @@ -67,8 +67,15 @@ describe("fileBreadcrumbChildren", () => { ]); }); - it("uses natural file-name ordering", () => { - expect(fileBreadcrumbChildren(entries, "src/lib").map((entry) => entry.label)).toEqual([ + it("uses natural file-name ordering and preserves input order for equivalent names", () => { + const files = ["file10.ts", "File2.ts", "file02.ts", "file2.ts"].map((name) => ({ + path: `src/lib/${name}`, + kind: "file" as const, + })); + + expect(fileBreadcrumbChildren(files, "src/lib").map((entry) => entry.label)).toEqual([ + "File2.ts", + "file02.ts", "file2.ts", "file10.ts", ]); diff --git a/apps/web/src/components/files/filePath.ts b/apps/web/src/components/files/filePath.ts index aea8266ec8b8..e819315310b8 100644 --- a/apps/web/src/components/files/filePath.ts +++ b/apps/web/src/components/files/filePath.ts @@ -36,6 +36,7 @@ export function fileBreadcrumbChildren( entries: readonly ProjectEntry[], directoryPath: string, ): FileBreadcrumbChild[] { + let collator: Intl.Collator | undefined; const prefix = directoryPath ? `${directoryPath}/` : ""; return entries .flatMap((entry) => { @@ -46,10 +47,11 @@ export function fileBreadcrumbChildren( }) .toSorted((left, right) => { if (left.kind !== right.kind) return left.kind === "directory" ? -1 : 1; - return left.label.localeCompare(right.label, undefined, { + collator ??= new Intl.Collator(undefined, { numeric: true, sensitivity: "base", }); + return collator.compare(left.label, right.label); }); } From 29d03ec556e94d5f847644730daf75ba4aa20678 Mon Sep 17 00:00:00 2001 From: Exotic <118054752+extoci@users.noreply.github.com> Date: Sun, 6 Sep 2026 20:19:01 +0300 Subject: [PATCH 224/320] style(web): fix inconsistencies in new settings layouts (#10177) Co-authored-by: shivam <91240327+shivamhwp@users.noreply.github.com> --- apps/web/src/components/settings/ConnectionsSettings.tsx | 7 +++++-- apps/web/src/components/settings/SourceControlSettings.tsx | 4 ++-- apps/web/src/components/settings/itemRows.ts | 4 ++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index b5de1e124801..ad7665651171 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -395,7 +395,7 @@ function formatDesktopSshConnectionError(error: unknown): string { return withoutTaggedErrorPrefix.trim() || fallback; } -const ENDPOINT_ROW_CLASSNAME = "rounded-xl px-3 py-2.5 sm:px-4"; +const ENDPOINT_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-2.5 sm:px-4"; type AccessSectionPresentation = "current" | "endpoint-rail"; @@ -405,7 +405,10 @@ function accessRowClassName(_presentation: AccessSectionPresentation) { function endpointRowClassName(presentation: AccessSectionPresentation, isAvailable: boolean) { if (presentation === "endpoint-rail") { - return cn("relative rounded-xl px-3 py-3 sm:px-4", !isAvailable && "bg-muted/15"); + return cn( + "relative first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4", + !isAvailable && "bg-muted/15", + ); } return cn(ENDPOINT_ROW_CLASSNAME, !isAvailable && "bg-muted/24"); diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 736b7f1b4b99..6d9d20105224 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -282,7 +282,7 @@ function DiscoveryItemRow({ return (
@@ -432,7 +432,7 @@ function SourceControlSectionSkeleton({ return ( {SOURCE_CONTROL_SKELETON_ROWS.map((row) => ( -
+
diff --git a/apps/web/src/components/settings/itemRows.ts b/apps/web/src/components/settings/itemRows.ts index e207c9ff7a78..0bad52bcb033 100644 --- a/apps/web/src/components/settings/itemRows.ts +++ b/apps/web/src/components/settings/itemRows.ts @@ -1,5 +1,5 @@ -/** Direct row in a settings section. Whitespace, rather than rules, separates peers. */ -export const ITEM_ROW_CLASSNAME = "rounded-xl px-3 py-3 sm:px-4"; +/** Direct row in a grouped settings section. Round only outer corners; the parent owns borders and separators. */ +export const ITEM_ROW_CLASSNAME = "first:rounded-t-xl last:rounded-b-xl px-3 py-3 sm:px-4"; export const ITEM_ROW_INNER_CLASSNAME = "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"; From 2d645df474f0af5e261edb56a79f7a2a9a2d442e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:13 -0700 Subject: [PATCH 225/320] feat(threads): persist manual active thread order (#9729) --- .../src/environment/ServerEnvironment.test.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/ProjectionPipeline.test.ts | 58 ++++- .../Layers/ProjectionPipeline.ts | 5 + .../Layers/ProjectionSnapshotQuery.test.ts | 6 + .../Layers/ProjectionSnapshotQuery.ts | 10 + .../decider.active-order.test.ts | 200 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 36 ++++ .../src/orchestration/projector.test.ts | 1 + apps/server/src/orchestration/projector.ts | 5 + .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + ...49_ProjectionThreadsActiveOrderKey.test.ts | 44 ++++ .../049_ProjectionThreadsActiveOrderKey.ts | 15 ++ .../persistence/Services/ProjectionThreads.ts | 1 + .../src/operations/commands.test.ts | 21 ++ .../client-runtime/src/operations/commands.ts | 11 + .../client-runtime/src/state/entities.test.ts | 6 + .../src/state/threadCommands.ts | 9 + .../client-runtime/src/state/threadDetail.ts | 2 + .../src/state/threadReducer.test.ts | 113 ++++++---- .../client-runtime/src/state/threadReducer.ts | 5 + .../src/state/threadSort.test.ts | 176 +++++++++++++++ .../client-runtime/src/state/threadSort.ts | 75 +++++-- packages/contracts/src/environment.ts | 2 + packages/contracts/src/orchestration.test.ts | 55 +++++ packages/contracts/src/orchestration.ts | 16 ++ 27 files changed, 824 insertions(+), 57 deletions(-) create mode 100644 apps/server/src/orchestration/decider.active-order.test.ts create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts create mode 100644 apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 141aa405af21..a674a25c1ec8 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -167,6 +167,7 @@ it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => { expect(second.capabilities.fileAttachments).toEqual({ maxUploadBytes: 50 * 1024 * 1024 }); expect(second.capabilities.pullRequests).toBe(true); expect(second.capabilities.usagePriceOverrides).toBe(true); + expect(second.capabilities.threadActiveReorder).toBe(true); expect(second.capabilities.threadTitleRegeneration).toBe(true); expect(second.capabilities.threadPullRequestLinking).toBe(true); expect(second.capabilities.agentActivityPublishing).toBe(false); diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index eab723d7909a..0b7f1761cf76 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -226,6 +226,7 @@ export const make = Effect.gen(function* () { usagePriceOverrides: true, threadPinning: true, threadPinReorder: true, + threadActiveReorder: true, threadTitleRegeneration: true, threadPullRequestLinking: true, environmentIcon: true, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index f619f6a93916..1a989958f50c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -488,6 +488,48 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { yield* sql`DROP TRIGGER count_thread_shell_updates`; yield* sql`DROP TABLE thread_shell_updates`; + // Replayed order events must survive later lifecycle upserts, whose + // complete SQL row writes otherwise risk dropping the placement. + const orderUpdatedAt = "2026-01-01T00:00:00.200Z"; + const orderEvents = [ + { type: "thread.meta-updated", payload: { activeOrderKey: "gm" } }, + { type: "thread.pinned", payload: { pinnedAt: now, pinOrderKey: "m" } }, + { + type: "thread.snoozed", + payload: { snoozedAt: now, snoozedUntil: "2026-01-02T00:00:00.000Z" }, + }, + { type: "thread.unsnoozed", payload: { reason: "user" } }, + { type: "thread.unpinned", payload: {} }, + { type: "thread.meta-updated", payload: { title: "Renamed" } }, + ] as const; + for (const [index, event] of orderEvents.entries()) { + yield* eventStore.append({ + type: event.type, + eventId: EventId.make(`evt-active-order-${index}`), + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + occurredAt: "2026-01-01T00:00:00.500Z", + commandId: CommandId.make(`cmd-active-order-${index}`), + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { + ...event.payload, + threadId: ThreadId.make("thread-1"), + updatedAt: orderUpdatedAt, + }, + }); + yield* projectionPipeline.bootstrap; + const rows = yield* sql<{ + readonly activeOrderKey: string | null; + readonly updatedAt: string; + }>` + SELECT active_order_key AS "activeOrderKey", updated_at AS "updatedAt" + FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ activeOrderKey: "gm", updatedAt: orderUpdatedAt }]); + } + // Settled lifecycle through the DB pipeline: thread.settled writes the // override + timestamp, thread.unsettled(user) flips to the active pin. yield* eventStore.append({ @@ -512,16 +554,23 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { readonly settledOverride: string | null; readonly settledAt: string | null; readonly unsettledAt: string | null; + readonly activeOrderKey: string | null; }>` SELECT settled_override AS "settledOverride", settled_at AS "settledAt", - unsettled_at AS "unsettledAt" + unsettled_at AS "unsettledAt", + active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' `; assert.deepEqual(settledRows, [ - { settledOverride: "settled", settledAt: "2026-01-01T00:00:01.000Z", unsettledAt: null }, + { + settledOverride: "settled", + settledAt: "2026-01-01T00:00:01.000Z", + unsettledAt: null, + activeOrderKey: null, + }, ]); yield* eventStore.append({ @@ -546,11 +595,13 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { readonly settledOverride: string | null; readonly settledAt: string | null; readonly unsettledAt: string | null; + readonly activeOrderKey: string | null; }>` SELECT settled_override AS "settledOverride", settled_at AS "settledAt", - unsettled_at AS "unsettledAt" + unsettled_at AS "unsettledAt", + active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' `; @@ -561,6 +612,7 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { settledOverride: "active", settledAt: null, unsettledAt: "2026-01-01T00:00:02.000Z", + activeOrderKey: null, }, ]); }), diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index bcef68170a49..050ad1a902ae 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -617,6 +617,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti snoozedAt: null, pinnedAt: null, pinOrderKey: null, + activeOrderKey: null, titleRegenerationRequestId: null, titleRegenerationStartedAt: null, latestUserMessageAt: null, @@ -671,6 +672,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: event.payload.updatedAt, }); return; @@ -790,6 +792,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(event.payload.activeOrderKey !== undefined + ? { activeOrderKey: event.payload.activeOrderKey } + : {}), ...(event.payload.titleRegeneration !== undefined ? { titleRegenerationRequestId: event.payload.titleRegeneration?.requestId ?? null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 08a07c0be75b..e262bce34aaf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -110,6 +110,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { has_actionable_proposed_plan, pinned_at, pin_order_key, + active_order_key, created_at, updated_at, deleted_at @@ -132,6 +133,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { 0, '2026-02-24T00:00:01.000Z', 'gm', + 'hq', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', NULL @@ -358,6 +360,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", + activeOrderKey: "hq", titleRegeneration: null, deletedAt: null, messages: [ @@ -487,6 +490,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { snoozedAt: null, pinnedAt: "2026-02-24T00:00:01.000Z", pinOrderKey: "gm", + activeOrderKey: "hq", titleRegeneration: null, session: { threadId: ThreadId.make("thread-1"), @@ -513,6 +517,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { } const commandSnapshot = yield* snapshotQuery.getCommandReadModel(); + assert.equal(commandSnapshot.threads[0]?.activeOrderKey, "hq"); assert.deepEqual(commandSnapshot.threads[0]?.branchPullRequest, branchPullRequest); const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1")); assert.equal(threadShell._tag, "Some"); @@ -560,6 +565,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ); assert.equal(detailWithoutActivities._tag, "Some"); if (detailWithoutActivities._tag === "Some") { + assert.equal(detailWithoutActivities.value.activeOrderKey, "hq"); assert.deepEqual(detailWithoutActivities.value.activities, []); assert.deepEqual(detailWithoutActivities.value.messages, snapshot.threads[0]?.messages); assert.deepEqual( diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b15b72ee5673..5f82a26e2a36 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -513,6 +513,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -552,6 +553,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -593,6 +595,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -1083,6 +1086,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -2082,6 +2086,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], @@ -2296,6 +2301,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, messages: [], @@ -2437,6 +2443,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2586,6 +2593,7 @@ pending_approval_requests AS ( snoozedAt: row.snoozedAt, pinnedAt: row.pinnedAt, pinOrderKey: row.pinOrderKey ?? null, + activeOrderKey: row.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, @@ -2908,6 +2916,7 @@ pending_approval_requests AS ( snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, + activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, @@ -3190,6 +3199,7 @@ pending_approval_requests AS ( snoozedAt: threadRow.value.snoozedAt, pinnedAt: threadRow.value.pinnedAt, pinOrderKey: threadRow.value.pinOrderKey ?? null, + activeOrderKey: threadRow.value.activeOrderKey ?? null, titleRegeneration: mapTitleRegeneration(threadRow.value), deletedAt: null, messages: messageRows.map((row) => { diff --git a/apps/server/src/orchestration/decider.active-order.test.ts b/apps/server/src/orchestration/decider.active-order.test.ts new file mode 100644 index 000000000000..58a7f5c054ec --- /dev/null +++ b/apps/server/src/orchestration/decider.active-order.test.ts @@ -0,0 +1,200 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type OrchestrationThread, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { projectEvent } from "./projector.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; +// The Effect test clock starts at the epoch. +const BEFORE_NOW = "1969-12-30T00:00:00.000Z"; +const SNOOZED_AT = "1969-12-31T00:00:00.000Z"; +const FUTURE_WAKE = "1970-01-02T00:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-1"); + +function makeReadModel(overrides: Partial = {}): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: THREAD_ID, + 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: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + unsettledAt: null, + activeOrderKey: null, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + pinOrderKey: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + ...overrides, + }, + ], + updatedAt: NOW, + }; +} + +const reorderCommand = { + type: "thread.active.reorder", + commandId: CommandId.make("cmd-active-reorder"), + threadId: THREAD_ID, + orderKey: "m", +} as const; + +it.layer(NodeServices.layer)("active thread ordering", (it) => { + it.effect("persists changed and repeated slots without changing thread activity timestamps", () => + Effect.gen(function* () { + let readModel = makeReadModel({ unsettledAt: BEFORE_NOW }); + for (const orderKey of ["m", "m", "g"]) { + const decided = yield* decideOrchestrationCommand({ + command: { ...reorderCommand, orderKey }, + readModel, + }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "thread.meta-updated", + payload: { threadId: THREAD_ID, activeOrderKey: orderKey, updatedAt: NOW }, + }); + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads[0]).toMatchObject({ + activeOrderKey: orderKey, + updatedAt: NOW, + createdAt: NOW, + unsettledAt: BEFORE_NOW, + }); + } + }), + ); + + for (const [label, overrides] of [ + ["archived", { archivedAt: NOW }], + ["deleted", { deletedAt: NOW }], + ["pinned", { pinnedAt: NOW }], + ["settled", { settledOverride: "settled", settledAt: NOW }], + ] satisfies ReadonlyArray]>) { + it.effect(`rejects reordering a ${label} thread`, () => + Effect.gen(function* () { + const error = yield* decideOrchestrationCommand({ + command: reorderCommand, + readModel: makeReadModel(overrides), + }).pipe(Effect.flip); + expect(error._tag).toBe("OrchestrationCommandInvariantError"); + }), + ); + } + + it.effect("reorders a running thread without affecting its session", () => + Effect.gen(function* () { + const readModel = makeReadModel({ + session: { + threadId: THREAD_ID, + status: "running", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: NOW, + }, + }); + const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + for (const event of events) { + const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); + expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); + } + }), + ); + + it.effect( + "changes a snoozed thread's retained slot without waking it or changing timestamps", + () => + Effect.gen(function* () { + const readModel = makeReadModel({ + activeOrderKey: "g", + snoozedAt: SNOOZED_AT, + snoozedUntil: FUTURE_WAKE, + unsettledAt: BEFORE_NOW, + }); + const decided = yield* decideOrchestrationCommand({ command: reorderCommand, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + expect(events).toHaveLength(1); + for (const event of events) { + const projected = yield* projectEvent(readModel, { ...event, sequence: 1 }); + expect(projected.threads[0]).toEqual({ ...readModel.threads[0], activeOrderKey: "m" }); + } + }), + ); + + it.effect("keeps placement through metadata, pin and snooze, then resets it on settlement", () => + Effect.gen(function* () { + let readModel = makeReadModel(); + const steps = [ + [reorderCommand, "m"], + [{ type: "thread.meta.update", title: "Renamed" }, "m"], + [{ type: "thread.pin", orderKey: "g" }, "m"], + [{ type: "thread.snooze", snoozedUntil: FUTURE_WAKE }, "m"], + [{ type: "thread.unsnooze", reason: "user" }, "m"], + [{ type: "thread.unpin" }, "m"], + [{ type: "thread.settle" }, null], + [{ type: "thread.unsettle", reason: "user" }, null], + [{ type: "thread.active.reorder", orderKey: "s" }, "s"], + ] as const; + for (const [index, [step, expectedKey]] of steps.entries()) { + const command: OrchestrationCommand = { + ...step, + commandId: CommandId.make(`lifecycle-${index}`), + threadId: THREAD_ID, + }; + const decided = yield* decideOrchestrationCommand({ command, readModel }); + const events = Array.isArray(decided) ? decided : [decided]; + for (const event of events) { + readModel = yield* projectEvent(readModel, { + ...event, + sequence: readModel.snapshotSequence + 1, + }); + } + expect(readModel.threads[0]?.activeOrderKey, command.type).toBe(expectedKey); + } + expect(readModel.threads[0]).toMatchObject({ + title: "Renamed", + settledOverride: "active", + settledAt: null, + snoozedUntil: null, + pinnedAt: null, + }); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index a20dba468c65..9dc55e1194cc 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -788,6 +788,42 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.active.reorder": { + const thread = yield* requireThreadNotArchived({ + readModel, + command, + threadId: command.threadId, + }); + const occurredAt = yield* nowIso; + // Snooze retains this slot. Changing it cannot wake the thread, and + // accepting it handles races with snooze and retained wake timestamps. + if ( + thread.deletedAt !== null || + thread.pinnedAt != null || + thread.settledOverride === "settled" + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `thread ${command.threadId} is not active and cannot be reordered`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt, + commandId: command.commandId, + })), + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + activeOrderKey: command.orderKey, + // Arranging the list is not thread activity or a lifecycle transition. + updatedAt: thread.updatedAt, + }, + }; + } + case "thread.meta.update": { const thread = yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index d4e1213b2abb..e973b523275f 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,7 @@ describe("orchestration projector", () => { createdAt: now, updatedAt: now, archivedAt: null, + activeOrderKey: null, settledOverride: null, settledAt: null, unsettledAt: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 58c4876905ec..c048247f4128 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -344,6 +344,7 @@ export function projectEvent( settledOverride: null, settledAt: null, unsettledAt: null, + activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -406,6 +407,7 @@ export function projectEvent( settledOverride: "settled", settledAt: payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: payload.updatedAt, }), })), @@ -500,6 +502,9 @@ export function projectEvent( ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(payload.activeOrderKey !== undefined + ? { activeOrderKey: payload.activeOrderKey } + : {}), ...(payload.titleRegeneration !== undefined ? { titleRegeneration: payload.titleRegeneration } : {}), diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 799386845419..6406e8237bc9 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -54,6 +54,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at, pinned_at, pin_order_key, + active_order_key, title_regeneration_request_id, title_regeneration_started_at, latest_user_message_at, @@ -84,6 +85,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.snoozedAt}, ${row.pinnedAt}, ${row.pinOrderKey ?? null}, + ${row.activeOrderKey ?? null}, ${row.titleRegenerationRequestId ?? null}, ${row.titleRegenerationStartedAt ?? null}, ${row.latestUserMessageAt}, @@ -114,6 +116,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at = excluded.snoozed_at, pinned_at = excluded.pinned_at, pin_order_key = excluded.pin_order_key, + active_order_key = excluded.active_order_key, title_regeneration_request_id = excluded.title_regeneration_request_id, title_regeneration_started_at = excluded.title_regeneration_started_at, latest_user_message_at = excluded.latest_user_message_at, @@ -151,6 +154,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", @@ -190,6 +194,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { snoozed_at AS "snoozedAt", pinned_at AS "pinnedAt", pin_order_key AS "pinOrderKey", + active_order_key AS "activeOrderKey", title_regeneration_request_id AS "titleRegenerationRequestId", title_regeneration_started_at AS "titleRegenerationStartedAt", latest_user_message_at AS "latestUserMessageAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 7f89170d29f5..c95f746d3648 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -60,6 +60,7 @@ import Migration0045 from "./Migrations/045_ProjectionProjectsAutoPull.ts"; import Migration0046 from "./Migrations/046_RepairAutomaticSettlementTimestamps.ts"; import Migration0047 from "./Migrations/047_ProjectionProjectIcon.ts"; import Migration0048 from "./Migrations/048_ProjectionThreadBranchPullRequest.ts"; +import Migration0049 from "./Migrations/049_ProjectionThreadsActiveOrderKey.ts"; /** * Migration loader with all migrations defined inline. @@ -120,6 +121,7 @@ export const migrationEntries = [ [46, "RepairAutomaticSettlementTimestamps", Migration0046], [47, "ProjectionProjectIcon", Migration0047], [48, "ProjectionThreadBranchPullRequest", Migration0048], + [49, "ProjectionThreadsActiveOrderKey", Migration0049], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts new file mode 100644 index 000000000000..138d25754d7e --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.test.ts @@ -0,0 +1,44 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; + +import { runMigrations } from "../Migrations.ts"; +import migrateActiveOrderKey from "./049_ProjectionThreadsActiveOrderKey.ts"; + +it.layer(NodeSqliteClient.layerMemory())("049_ProjectionThreadsActiveOrderKey", (it) => { + it.effect("migrates old threads without changing their timestamps or assigning an order", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 48 }); + const now = "2026-01-01T00:00:00.000Z"; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, + created_at, updated_at + ) VALUES ( + 'thread-1', 'project-1', 'Existing thread', + '{"instanceId":"codex","model":"gpt-5.4"}', 'full-access', ${now}, ${now} + ) + `; + yield* runMigrations({ toMigrationInclusive: 49 }); + const migrated = yield* sql<{ readonly activeOrderKey: string | null }>` + SELECT active_order_key AS "activeOrderKey" FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(migrated, [{ activeOrderKey: null }]); + // Recovery may run the same migration against a database that already + // has the column, including a placement written after the upgrade. + yield* sql`UPDATE projection_threads SET active_order_key = 'gm' WHERE thread_id = 'thread-1'`; + yield* migrateActiveOrderKey; + const rows = yield* sql<{ + readonly activeOrderKey: string | null; + readonly createdAt: string; + readonly updatedAt: string; + }>` + SELECT active_order_key AS "activeOrderKey", created_at AS "createdAt", updated_at AS "updatedAt" + FROM projection_threads WHERE thread_id = 'thread-1' + `; + assert.deepEqual(rows, [{ activeOrderKey: "gm", createdAt: now, updatedAt: now }]); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts new file mode 100644 index 000000000000..6f40ec38d081 --- /dev/null +++ b/apps/server/src/persistence/Migrations/049_ProjectionThreadsActiveOrderKey.ts @@ -0,0 +1,15 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const columns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!columns.some((column) => column.name === "active_order_key")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN active_order_key TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index ee442624be51..0a8b2e31c5ab 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -47,6 +47,7 @@ export const ProjectionThread = Schema.Struct({ snoozedAt: Schema.NullOr(IsoDateTime), pinnedAt: Schema.NullOr(IsoDateTime), pinOrderKey: Schema.optional(Schema.NullOr(Schema.String)), + activeOrderKey: Schema.optional(Schema.NullOr(Schema.String)), titleRegenerationRequestId: Schema.optional(Schema.NullOr(CommandId)), titleRegenerationStartedAt: Schema.optional(Schema.NullOr(IsoDateTime)), latestUserMessageAt: Schema.NullOr(IsoDateTime), diff --git a/packages/client-runtime/src/operations/commands.test.ts b/packages/client-runtime/src/operations/commands.test.ts index 36bc6a7b296f..5cc17586471d 100644 --- a/packages/client-runtime/src/operations/commands.test.ts +++ b/packages/client-runtime/src/operations/commands.test.ts @@ -24,6 +24,7 @@ import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; import { archiveThread, createProject, + reorderActiveThread, settleThread, stopThreadSession, unsettleThread, @@ -172,4 +173,24 @@ describe("environment commands", () => { ]); }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), ); + + it.effect("sends an active order key without changing activity timestamps", () => + Effect.gen(function* () { + const dispatched: ClientOrchestrationCommand[] = []; + const supervisor = yield* makeSupervisor(dispatched); + yield* reorderActiveThread({ + commandId: CommandId.make("reorder-command"), + threadId: ThreadId.make("thread-1"), + orderKey: "mf", + }).pipe(Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor)); + expect(dispatched).toEqual([ + { + type: "thread.active.reorder", + commandId: "reorder-command", + threadId: "thread-1", + orderKey: "mf", + }, + ]); + }).pipe(Effect.provide(TEST_CRYPTO_LAYER)), + ); }); diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..9bf75c838a99 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -42,6 +42,7 @@ export type UnsnoozeThreadInput = CommandInput<"thread.unsnooze">; export type PinThreadInput = CommandInput<"thread.pin">; export type UnpinThreadInput = CommandInput<"thread.unpin">; export type ReorderPinnedThreadInput = CommandInput<"thread.pin.reorder">; +export type ReorderActiveThreadInput = CommandInput<"thread.active.reorder">; export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; @@ -230,6 +231,16 @@ export const reorderPinnedThread: (input: ReorderPinnedThreadInput) => CommandEf }); }); +export const reorderActiveThread: (input: ReorderActiveThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.reorderActiveThread", +)(function* (input) { + return yield* dispatch({ + ...input, + type: "thread.active.reorder", + commandId: yield* commandId(input), + }); +}); + export const updateThreadMetadata: (input: UpdateThreadMetadataInput) => CommandEffect = Effect.fn( "EnvironmentCommands.updateThreadMetadata", )(function* (input) { diff --git a/packages/client-runtime/src/state/entities.test.ts b/packages/client-runtime/src/state/entities.test.ts index d02f63c0b69a..b8d2aef40697 100644 --- a/packages/client-runtime/src/state/entities.test.ts +++ b/packages/client-runtime/src/state/entities.test.ts @@ -208,6 +208,8 @@ describe("environment entity projections", () => { title: "Cached thread", branch: "stale-branch", worktreePath: "/repo/stale-worktree", + activeOrderKey: "t", + unsettledAt: "2026-03-09T10:00:00.000Z", deletedAt: null, messages, proposedPlans: [], @@ -220,6 +222,8 @@ describe("environment entity projections", () => { title: "Current thread", branch: "current-branch", worktreePath: "/repo/current-worktree", + activeOrderKey: "f", + unsettledAt: "2026-03-09T12:00:00.000Z", }; const merged = mergeEnvironmentThread(detail, shell); @@ -228,6 +232,8 @@ describe("environment entity projections", () => { title: "Current thread", branch: "current-branch", worktreePath: "/repo/current-worktree", + activeOrderKey: "f", + unsettledAt: "2026-03-09T12:00:00.000Z", }); expect(merged?.messages).toBe(messages); }); diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index c540644289df..83881f7ec15f 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -19,6 +19,7 @@ import { type SetThreadRuntimeModeInput, type PinThreadInput, type ReorderPinnedThreadInput, + type ReorderActiveThreadInput, type SettleThreadInput, type SnoozeThreadInput, type StartThreadTurnInput, @@ -39,6 +40,7 @@ import { setThreadRuntimeMode, pinThread, reorderPinnedThread, + reorderActiveThread, settleThread, snoozeThread, startThreadTurn, @@ -63,6 +65,7 @@ export type { SetThreadRuntimeModeInput, PinThreadInput, ReorderPinnedThreadInput, + ReorderActiveThreadInput, SettleThreadInput, SnoozeThreadInput, StartThreadTurnInput, @@ -150,6 +153,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + reorderActive: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:reorder-active", + execute: (input: ReorderActiveThreadInput) => reorderActiveThread(input), + scheduler, + concurrency, + }), updateMetadata: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:update-metadata", execute: (input: UpdateThreadMetadataInput) => updateThreadMetadata(input), diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index 0233cee0e22e..379985b71243 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -58,6 +58,8 @@ export function mergeEnvironmentThread( archivedAt: shell.archivedAt, settledOverride: shell.settledOverride, settledAt: shell.settledAt, + unsettledAt: shell.unsettledAt, + activeOrderKey: shell.activeOrderKey, snoozedUntil: shell.snoozedUntil, snoozedAt: shell.snoozedAt, pinnedAt: shell.pinnedAt, diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 59b3cb0551e4..38afce2d5cb7 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -178,24 +178,28 @@ describe("applyThreadDetailEvent", () => { describe("thread.settled / thread.unsettled", () => { it("sets the settled override and timestamp", () => { const settledAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: settledAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.settled", - payload: { - threadId: ThreadId.make("thread-1"), - settledAt, - updatedAt: settledAt, + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: settledAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.settled", + payload: { + threadId: ThreadId.make("thread-1"), + settledAt, + updatedAt: settledAt, + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.settledOverride).toBe("settled"); expect(result.thread.settledAt).toBe(settledAt); + expect(result.thread.activeOrderKey).toBeNull(); } }); @@ -234,23 +238,27 @@ describe("applyThreadDetailEvent", () => { describe("thread.pinned / thread.unpinned", () => { it("sets pinnedAt", () => { const pinnedAt = "2026-04-01T05:00:00.000Z"; - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: pinnedAt, - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.pinned", - payload: { - threadId: ThreadId.make("thread-1"), - pinnedAt, - updatedAt: pinnedAt, + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: pinnedAt, + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.pinned", + payload: { + threadId: ThreadId.make("thread-1"), + pinnedAt, + updatedAt: pinnedAt, + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.pinnedAt).toBe(pinnedAt); + expect(result.thread.activeOrderKey).toBe("m"); } }); @@ -281,26 +289,57 @@ describe("applyThreadDetailEvent", () => { }); describe("thread.meta-updated", () => { + it.each(["f", null] as const)( + "updates the active key to %s without activity", + (activeOrderKey) => { + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: baseThread.id, + type: "thread.meta-updated", + payload: { + threadId: baseThread.id, + activeOrderKey, + updatedAt: baseThread.updatedAt, + }, + }, + ); + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.activeOrderKey).toBe(activeOrderKey); + expect(result.thread.updatedAt).toBe(baseThread.updatedAt); + } + }, + ); + it("patches title and branch", () => { - const result = applyThreadDetailEvent(baseThread, { - ...baseEventFields, - sequence: 5, - occurredAt: "2026-04-01T05:00:00.000Z", - aggregateKind: "thread", - aggregateId: ThreadId.make("thread-1"), - type: "thread.meta-updated", - payload: { - threadId: ThreadId.make("thread-1"), - title: "Updated Title", - branch: "feature/demo", - updatedAt: "2026-04-01T05:00:00.000Z", + const result = applyThreadDetailEvent( + { ...baseThread, activeOrderKey: "m" }, + { + ...baseEventFields, + sequence: 5, + occurredAt: "2026-04-01T05:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.meta-updated", + payload: { + threadId: ThreadId.make("thread-1"), + title: "Updated Title", + branch: "feature/demo", + updatedAt: "2026-04-01T05:00:00.000Z", + }, }, - }); + ); expect(result.kind).toBe("updated"); if (result.kind === "updated") { expect(result.thread.title).toBe("Updated Title"); expect(result.thread.branch).toBe("feature/demo"); + expect(result.thread.activeOrderKey).toBe("m"); // Model selection should be unchanged since it wasn't in the payload expect(result.thread.modelSelection).toEqual(baseThread.modelSelection); } diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 09272cd065cf..a3481fdc729c 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -105,6 +105,7 @@ export function applyThreadDetailEvent( settledOverride: null, settledAt: null, unsettledAt: null, + activeOrderKey: null, snoozedUntil: null, snoozedAt: null, deletedAt: null, @@ -144,6 +145,7 @@ export function applyThreadDetailEvent( settledOverride: "settled", settledAt: event.payload.settledAt, unsettledAt: null, + activeOrderKey: null, updatedAt: event.payload.updatedAt, }, }; @@ -244,6 +246,9 @@ export function applyThreadDetailEvent( ...(event.payload.branchPullRequest !== undefined ? { branchPullRequest: event.payload.branchPullRequest } : {}), + ...(event.payload.activeOrderKey !== undefined + ? { activeOrderKey: event.payload.activeOrderKey } + : {}), updatedAt: event.payload.updatedAt, }, }; diff --git a/packages/client-runtime/src/state/threadSort.test.ts b/packages/client-runtime/src/state/threadSort.test.ts index a51d4cfed093..d9a2c124ee7d 100644 --- a/packages/client-runtime/src/state/threadSort.test.ts +++ b/packages/client-runtime/src/state/threadSort.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "vite-plus/test"; import { + generateSpreadPinOrderKeys, + pinOrderKeyBetween, planPinnedMove, + planPinnedReorder, resolveSettledThreadTimestamp, + sortActiveThreadsByOrderKey, sortPinnedThreadsByOrderKey, sortThreads, type ThreadSortInput, @@ -108,6 +112,44 @@ describe("sortThreads", () => { }); }); +describe("planPinnedReorder with hidden rows", () => { + it("keeps hidden slots available when inserting between visible neighbors", () => { + const midpoint = pinOrderKeyBetween("f", "t")!; + const keysById = new Map([ + ["a", "f"], + ["b", "t"], + ["moved", "z"], + ["snoozed", midpoint], + ]); + const assignments = planPinnedReorder({ + orderedIds: ["a", "moved", "b"], + keysById, + movedId: "moved", + }); + expect(assignments).toHaveLength(1); + const key = assignments[0]!.orderKey; + expect(key > "f" && key < "t").toBe(true); + expect(key).not.toBe(midpoint); + expect(assignments[0]!.id).toBe("moved"); + }); + + it("materializes keyless rows without overwriting hidden slots", () => { + const reserved = generateSpreadPinOrderKeys(6); + const keysById = new Map([ + ["a", null], + ["b", null], + ["c", null], + ...reserved.map((key, i) => [`hidden-${i}`, key] as const), + ]); + const assignments = planPinnedReorder({ orderedIds: ["c", "a", "b"], keysById, movedId: "c" }); + expect(assignments.map(({ id }) => id)).toEqual(["c", "a", "b"]); + const keys = assignments.map(({ orderKey }) => orderKey); + expect(keys).toEqual([...keys].sort()); + expect(new Set(keys).size).toBe(3); + expect(keys.every((key) => !reserved.includes(key))).toBe(true); + }); +}); + describe("planPinnedMove", () => { it("moves a thread up with a single key write", () => { const assignments = planPinnedMove({ @@ -173,3 +215,137 @@ describe("sortPinnedThreadsByOrderKey", () => { expect(sorted.map((thread) => thread.environmentId)).toEqual(["env-a", "env-b"]); }); }); + +describe("generateSpreadPinOrderKeys", () => { + it.each([0, 1, 650, 675, 676, 1_001, 2_000])( + "leaves unique, insertable keys for %i threads", + (count) => { + const keys = generateSpreadPinOrderKeys(count); + expect(keys).toHaveLength(count); + expect(new Set(keys).size).toBe(count); + expect([...keys].sort()).toEqual(keys); + for (let index = 0; index < keys.length; index += 1) { + const before = keys[index - 1] ?? null; + const after = keys[index]!; + expect(after).toMatch(/^[a-z]*[b-z]$/); + const between = pinOrderKeyBetween(before, after); + expect(between).not.toBeNull(); + expect(between! < after).toBe(true); + if (before !== null) expect(between! > before).toBe(true); + } + }, + ); +}); + +describe("sortActiveThreadsByOrderKey", () => { + it("keeps new and reopened threads ahead of the saved order", () => { + const sorted = sortActiveThreadsByOrderKey([ + { + id: "arranged-first", + createdAt: "2026-03-09T09:00:00.000Z", + activeOrderKey: "f", + }, + { + id: "new", + createdAt: "2026-03-09T11:00:00.000Z", + activeOrderKey: null, + }, + { + id: "arranged-last", + createdAt: "2026-03-09T12:00:00.000Z", + unsettledAt: "2026-03-09T13:00:00.000Z", + activeOrderKey: "t", + }, + { + id: "reopened", + createdAt: "2026-03-01T09:00:00.000Z", + unsettledAt: "2026-03-09T12:00:00.000Z", + }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual([ + "reopened", + "new", + "arranged-first", + "arranged-last", + ]); + }); + + it("breaks equal order keys and timestamps by thread then environment", () => { + for (const activeOrderKey of [null, "m"]) { + const threads = [ + { id: "thread-b", environmentId: "env-a" }, + { id: "thread-a", environmentId: "env-b" }, + { id: "thread-a", environmentId: "env-a" }, + ].map((thread) => ({ + ...thread, + createdAt: "2026-03-09T10:00:00.000Z", + activeOrderKey, + })); + expect( + sortActiveThreadsByOrderKey(threads).map( + (thread) => `${thread.id}:${thread.environmentId}`, + ), + ).toEqual(["thread-a:env-a", "thread-a:env-b", "thread-b:env-a"]); + } + }); + + it("applies every move across a mixed keyless and keyed section", () => { + const threads = Array.from({ length: 6 }, (_, index) => ({ + id: String(index), + createdAt: `2026-03-09T0${6 - index}:00:00.000Z`, + activeOrderKey: index < 3 ? null : ["f", "m", "t"][index - 3]!, + })); + const ids = threads.map((thread) => thread.id); + const keysById = new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])); + for (const movedId of ids) { + for (let targetIndex = 0; targetIndex < ids.length; targetIndex += 1) { + const desired = ids.filter((id) => id !== movedId); + desired.splice(targetIndex, 0, movedId); + const assignments = planPinnedReorder({ orderedIds: desired, keysById, movedId }); + const nextKeys = new Map( + assignments.map((assignment) => [assignment.id, assignment.orderKey]), + ); + const updated = threads.map((thread) => ({ + ...thread, + activeOrderKey: nextKeys.get(thread.id) ?? thread.activeOrderKey, + })); + expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(desired); + } + } + }); + + it("moves a keyless thread into the arranged run with one write", () => { + const assignments = planPinnedMove({ + orderedIds: ["new", "reopened", "first", "last"], + keysById: new Map([ + ["new", null], + ["reopened", null], + ["first", "f"], + ["last", "t"], + ]), + movedId: "reopened", + direction: "down", + }); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe("reopened"); + expect(assignments![0]!.orderKey > "f").toBe(true); + expect(assignments![0]!.orderKey < "t").toBe(true); + }); + + it("materializes a large active list without changing the requested order", () => { + const threads = Array.from({ length: 1_200 }, (_, index) => ({ + id: String(index), + createdAt: "2026-03-09T10:00:00.000Z", + activeOrderKey: null as string | null, + })); + const orderedIds = threads.map((thread) => thread.id).toReversed(); + const assignments = planPinnedReorder({ + orderedIds, + movedId: orderedIds[0]!, + keysById: new Map(threads.map((thread) => [thread.id, thread.activeOrderKey])), + }); + const keys = new Map(assignments.map((assignment) => [assignment.id, assignment.orderKey])); + const updated = threads.map((thread) => ({ ...thread, activeOrderKey: keys.get(thread.id) })); + expect(sortActiveThreadsByOrderKey(updated).map((thread) => thread.id)).toEqual(orderedIds); + }); +}); diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index cf89d4a21ac9..f06c95919554 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -205,25 +205,27 @@ export function pinOrderKeyBetween(before: string | null, after: string | null): return pinOrderMidpoint(a, b); } -/** Evenly spaced keys for rewriting a whole pinned section (used when a - drop lands next to keyless threads, so single-key insertion has nothing - to anchor on). Two base-26 digits give 675 slots — far beyond any real - pinned section — with monotonicity enforced as a belt-and-braces. */ -function generateSpreadPinOrderKeys(count: number): string[] { - const space = PIN_ORDER_DIGITS.length * PIN_ORDER_DIGITS.length; +/** Evenly spaced keys for materializing an order. Wider keys keep a large + active list from exhausting the space between two-digit keys. */ +export function generateSpreadPinOrderKeys(count: number): string[] { + let width = 2; + let space = PIN_ORDER_DIGITS.length ** width; + while (space <= (count + 1) * 2) { + width += 1; + space *= PIN_ORDER_DIGITS.length; + } const step = space / (count + 1); const keys: string[] = []; - let previous = 0; for (let i = 0; i < count; i += 1) { - let value = Math.max(Math.round(step * (i + 1)), previous + 1); + let value = Math.round(step * (i + 1)); // Skip values whose low digit is the minimum (a trailing "a" key). if (value % PIN_ORDER_DIGITS.length === 0) value += 1; - value = Math.min(value, space - 1); - previous = value; - keys.push( - PIN_ORDER_DIGITS.charAt(Math.floor(value / PIN_ORDER_DIGITS.length)) + - PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length), - ); + let key = ""; + for (let digit = 0; digit < width; digit += 1) { + key = PIN_ORDER_DIGITS.charAt(value % PIN_ORDER_DIGITS.length) + key; + value = Math.floor(value / PIN_ORDER_DIGITS.length); + } + keys.push(key); } return keys; } @@ -233,15 +235,21 @@ function generateSpreadPinOrderKeys(count: number): string[] { * sits between two keyed (or absent) neighbors, this is a single write to * the moved thread. When a neighbor is keyless (threads pinned before * reordering shipped), the whole section gets fresh spread keys — a - * one-time materialization; every move after that is single-write. + * one-time materialization; every move after that is single-write. Active + * reordering uses the same planner with activeOrderKey values. */ export function planPinnedReorder(input: { /** Thread ids in the desired visual order (after the move). */ readonly orderedIds: readonly string[]; + /** Include retained keys from hidden rows; only orderedIds receive writes. */ readonly keysById: ReadonlyMap; readonly movedId: string; }): ReadonlyArray<{ readonly id: string; readonly orderKey: string }> { const { orderedIds, keysById, movedId } = input; + const visibleIds = new Set(orderedIds); + const reservedKeys = new Set( + [...keysById].flatMap(([id, key]) => (!visibleIds.has(id) && key != null ? [key] : [])), + ); const movedIndex = orderedIds.indexOf(movedId); if (movedIndex === -1) return []; const beforeId = movedIndex > 0 ? orderedIds[movedIndex - 1] : null; @@ -251,11 +259,14 @@ export function planPinnedReorder(input: { const beforeUsable = beforeId === null || beforeKey != null; const afterUsable = afterId === null || afterKey != null; if (beforeUsable && afterUsable) { - const key = pinOrderKeyBetween(beforeKey, afterKey); + let key = pinOrderKeyBetween(beforeKey, afterKey); + while (key !== null && reservedKeys.has(key)) key = pinOrderKeyBetween(key, afterKey); if (key !== null) return [{ id: movedId, orderKey: key }]; } // Keyless neighbor (or corrupt keys): rewrite the section in the new order. - const keys = generateSpreadPinOrderKeys(orderedIds.length); + const keys = generateSpreadPinOrderKeys(orderedIds.length + reservedKeys.size) + .filter((key) => !reservedKeys.has(key)) + .slice(0, orderedIds.length); return orderedIds.flatMap((id, index) => { const key = keys[index]!; return keysById.get(id) === key ? [] : [{ id, orderKey: key }]; @@ -303,6 +314,36 @@ export function sortPinnedThreadsByOrderKey< return [...keyed, ...keyless]; } +/** New and reopened threads lead the active list. Arranged threads follow + their saved keys; activity leaves both groups in place. */ +export function sortActiveThreadsByOrderKey< + T extends { + readonly id: string; + readonly createdAt: string; + readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; + }, +>(threads: readonly T[]): T[] { + return [...threads].sort((left, right) => { + const leftKey = left.activeOrderKey; + const rightKey = right.activeOrderKey; + if (leftKey == null && rightKey != null) return -1; + if (leftKey != null && rightKey == null) return 1; + let order = 0; + if (leftKey != null && rightKey != null) { + order = leftKey < rightKey ? -1 : leftKey > rightKey ? 1 : 0; + } else { + order = activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left); + } + return ( + order || + left.id.localeCompare(right.id) || + (left.environmentId ?? "").localeCompare(right.environmentId ?? "") + ); + }); +} + /** * planPinnedReorder specialized for mobile's Move up / Move down menu * actions: swap the moved thread with its displayed neighbor. Null when the diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 6b815591ff2b..94d7c6081250 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -116,6 +116,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server understands thread.pin.reorder (and orderKey on thread.pin). Same version-skew contract as threadSettlement. */ threadPinReorder: Schema.optionalKey(Schema.Boolean), + /** Server persists manual Active order through thread.active.reorder. */ + threadActiveReorder: Schema.optionalKey(Schema.Boolean), /** Server understands regenerateTitle on thread.meta.update. Absent on older servers, so clients hide the action instead of sending it. */ threadTitleRegeneration: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index 61dbdb9a5512..b3dbc4188c23 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -784,6 +784,61 @@ it.effect("accepts a title seed in thread.turn.start", () => }), ); +it.effect("decodes active reorder commands through client and orchestration boundaries", () => + Effect.gen(function* () { + const input = { + type: "thread.active.reorder", + commandId: "cmd-active-reorder", + threadId: "thread-1", + orderKey: "gm", + }; + const clientCommand = yield* decodeClientOrchestrationCommand(input); + const command = yield* decodeOrchestrationCommand(input); + for (const decoded of [clientCommand, command]) { + assert.strictEqual(decoded.type, "thread.active.reorder"); + if (decoded.type === "thread.active.reorder") { + assert.strictEqual(decoded.threadId, "thread-1"); + assert.strictEqual(decoded.orderKey, "gm"); + } + } + const emptyKey = yield* Effect.exit( + decodeClientOrchestrationCommand({ ...input, orderKey: " " }), + ); + assert.isTrue(Exit.isFailure(emptyKey)); + }), +); + +it.effect("decodes active placement on existing metadata events while accepting old payloads", () => + Effect.gen(function* () { + const payload = { threadId: "thread-1", updatedAt: "2026-01-01T00:00:00.000Z" }; + const oldPayload = yield* decodeThreadMetaUpdatedPayload(payload); + assert.strictEqual(oldPayload.activeOrderKey, undefined); + const resetPayload = yield* decodeThreadMetaUpdatedPayload({ + ...payload, + activeOrderKey: null, + }); + assert.strictEqual(resetPayload.activeOrderKey, null); + const event = yield* decodeOrchestrationEvent({ + type: "thread.meta-updated", + sequence: 1, + eventId: "event-active-reorder", + aggregateKind: "thread", + aggregateId: "thread-1", + occurredAt: "2026-01-02T00:00:00.000Z", + commandId: "cmd-active-reorder", + causationEventId: null, + correlationId: null, + metadata: {}, + payload: { ...payload, activeOrderKey: "gm" }, + }); + assert.strictEqual(event.type, "thread.meta-updated"); + if (event.type === "thread.meta-updated") { + assert.strictEqual(event.payload.activeOrderKey, "gm"); + assert.strictEqual(event.payload.updatedAt, payload.updatedAt); + } + }), +); + it.effect("accepts a title regeneration intent in thread.meta.update", () => Effect.gen(function* () { const parsed = yield* decodeOrchestrationCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 47501e323441..37f5476fecc8 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -523,6 +523,9 @@ export const OrchestrationThread = Schema.Struct({ // servers never need each other's threads to agree on the merged list. // Optional so payloads from pre-reorder servers still decode. pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + // Manual Active placement. Keyless threads retain their creation/re-entry + // order above the arranged run. Settling clears this slot. + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), // Pending-only state. Optional so older servers remain compatible. titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), deletedAt: Schema.NullOr(IsoDateTime), @@ -588,6 +591,7 @@ export const OrchestrationThreadShell = Schema.Struct({ snoozedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), pinOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), @@ -895,6 +899,13 @@ const ThreadPinReorderCommand = Schema.Struct({ orderKey: TrimmedNonEmptyString, }); +const ThreadActiveReorderCommand = Schema.Struct({ + type: Schema.Literal("thread.active.reorder"), + commandId: CommandId, + threadId: ThreadId, + orderKey: TrimmedNonEmptyString, +}); + const ThreadMetaUpdateCommand = Schema.Struct({ type: Schema.Literal("thread.meta.update"), commandId: CommandId, @@ -1058,6 +1069,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1086,6 +1098,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadPinCommand, ThreadUnpinCommand, ThreadPinReorderCommand, + ThreadActiveReorderCommand, ThreadMetaUpdateCommand, ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, @@ -1375,6 +1388,9 @@ export const ThreadPinReorderedPayload = Schema.Struct({ export const ThreadMetaUpdatedPayload = Schema.Struct({ threadId: ThreadId, + // Order updates use this existing event so older clients can ignore the + // new field while continuing to decode the event stream. + activeOrderKey: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), title: Schema.optional(TrimmedNonEmptyString), /** Intent marker consumed by the title-generation reactor. Keeping this on the existing event lets older clients safely ignore the new field. */ From 6766e682ac1a0aa33020358ba98707f4f8fee467 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:14 -0700 Subject: [PATCH 226/320] feat(mobile): arrange active threads from both thread lists (#9730) --- .../src/features/home/HomeRouteScreen.tsx | 4 +- apps/mobile/src/features/home/HomeScreen.tsx | 94 ++++-- .../src/features/home/useThreadListActions.ts | 108 ++++--- .../threads/ThreadNavigationSidebar.tsx | 89 ++++-- .../features/threads/thread-list-v2-items.tsx | 103 +++--- .../src/features/threads/threadListV2.test.ts | 293 ++++++++++++++++++ .../src/features/threads/threadListV2.ts | 86 +++-- .../src/features/threads/threadOrder.ts | 120 +++++++ apps/mobile/src/state/thread-order.test.ts | 117 +++++++ apps/mobile/src/state/thread-order.ts | 89 ++++++ apps/mobile/src/state/use-thread-selection.ts | 4 + 11 files changed, 934 insertions(+), 173 deletions(-) create mode 100644 apps/mobile/src/features/threads/threadOrder.ts create mode 100644 apps/mobile/src/state/thread-order.test.ts create mode 100644 apps/mobile/src/state/thread-order.ts diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index 943303202216..a9833d2d619f 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -49,7 +49,7 @@ export function HomeRouteScreen() { unsnoozeThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, unsettleThread, } = useThreadListActions(); @@ -199,7 +199,7 @@ export function HomeRouteScreen() { onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onMovePinnedThread={movePinnedThread} + onMoveThread={moveThread} onRegenerateThreadTitle={regenerateThreadTitle} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 798a6a840c94..4a0095165a2c 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -1,3 +1,4 @@ +import { createThreadMovePlanner } from "../threads/threadOrder"; import { LegendList, type LegendListRef, @@ -11,7 +12,6 @@ import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, } from "@t3tools/client-runtime/state/thread-search"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { type EnvironmentId, resolveEnvironmentMachineKind, @@ -35,6 +35,7 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; +import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { @@ -51,6 +52,7 @@ import { } from "../threads/thread-list-v2-items"; import { buildThreadListV2Items, + getThreadListV2OrderedSection, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, @@ -114,7 +116,7 @@ interface HomeScreenProps { readonly onUnsettleThread: (thread: EnvironmentThreadShell) => void; readonly onPinThread: (thread: EnvironmentThreadShell) => Promise; readonly onUnpinThread: (thread: EnvironmentThreadShell) => Promise; - readonly onMovePinnedThread: ( + readonly onMoveThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; @@ -512,11 +514,11 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.onPinThread], ); - const handleMovePinnedThread = useCallback( + const handleMoveThread = useCallback( (thread: EnvironmentThreadShell, direction: "up" | "down") => { - void props.onMovePinnedThread(thread, direction); + void props.onMoveThread(thread, direction); }, - [props.onMovePinnedThread], + [props.onMoveThread], ); const handleUnpinThread = useCallback( (thread: EnvironmentThreadShell) => { @@ -608,6 +610,15 @@ export function HomeScreen(props: HomeScreenProps) { } return supported; }, [serverConfigs]); + const activeReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadActiveReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -627,20 +638,40 @@ export function HomeScreen(props: HomeScreenProps) { ), [serverConfigs], ); - // Canonical arranged pinned order (reorder-capable threads only) for the - // Move up/down position flags. Computed from all shells, not the rendered - // list, so search/scope filtering never disables or misdirects a move. - const arrangedPinnedKeys = useMemo(() => { - const pinned = sortPinnedThreadsByOrderKey( - props.threads.filter( - (thread) => - thread.pinnedAt != null && - thread.archivedAt === null && - pinReorderEnvironmentIds.has(thread.environmentId), - ), - ); - return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); - }, [pinReorderEnvironmentIds, props.threads]); + const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadMovePlanners = useMemo(() => { + const sectionPlanner = (section: "pinned" | "active") => + createThreadMovePlanner({ + allThreads: props.threads, + section, + reorderableEnvironmentIds: new Set( + [...serverConfigs].flatMap(([id, config]) => + (section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder) === true + ? [id] + : [], + ), + ), + ordered: getThreadListV2OrderedSection({ + threads: props.threads, + section, + pendingOrder, + now: new Date().toISOString(), + settlementEnvironmentIds, + snoozeEnvironmentIds, + }), + }); + return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + }, [ + serverConfigs, + props.threads, + pendingOrder, + settlementEnvironmentIds, + snoozeEnvironmentIds, + nowMinute, + snoozeWakeTick, + ]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -655,6 +686,7 @@ export function HomeScreen(props: HomeScreenProps) { // Settled threads are live shells; archived threads keep their original // "hidden from lists" meaning. return buildThreadListV2Items({ + pendingOrder, threads: props.threads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, @@ -669,6 +701,7 @@ export function HomeScreen(props: HomeScreenProps) { selectedThreadKey: null, }); }, [ + pendingOrder, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -784,6 +817,8 @@ export function HomeScreen(props: HomeScreenProps) { ); } const thread = item.item.thread; + const movePlanner = item.item.pinned ? threadMovePlanners.pinned : threadMovePlanners.active; + const movedId = `${thread.environmentId}:${thread.id}`; return ( 0} - canMovePinnedDown={(() => { - const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); - return index !== -1 && index < arrangedPinnedKeys.length - 1; - })()} + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId) + } + canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} + canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={handleSnoozeThread} onUnsnoozeThread={handleUnsnoozeThread} onUnsettleThread={handleUnsettleThread} onPinThread={handlePinThread} onUnpinThread={handleUnpinThread} - onMovePinnedThread={handleMovePinnedThread} + onMoveThread={handleMoveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} /> @@ -849,8 +885,10 @@ export function HomeScreen(props: HomeScreenProps) { }, [ handleDeleteThread, - arrangedPinnedKeys, - handleMovePinnedThread, + activeReorderEnvironmentIds, + threadMovePlanners, + pendingOrder, + handleMoveThread, handlePinThread, handleRegenerateThreadTitle, handleSettleThread, diff --git a/apps/mobile/src/features/home/useThreadListActions.ts b/apps/mobile/src/features/home/useThreadListActions.ts index dae6c46a89dd..7b0b7b701106 100644 --- a/apps/mobile/src/features/home/useThreadListActions.ts +++ b/apps/mobile/src/features/home/useThreadListActions.ts @@ -8,15 +8,14 @@ import { Alert } from "react-native"; import { showConfirmDialog } from "../../components/ConfirmDialogHost"; import { scopedThreadKey } from "../../lib/scopedEntities"; import { refreshArchivedThreadsForEnvironment } from "../archive/useArchivedThreadSnapshots"; -import { - pinOrderKeyBetween, - planPinnedMove, - sortPinnedThreadsByOrderKey, -} from "@t3tools/client-runtime/state/thread-sort"; +import { pinOrderKeyBetween } from "@t3tools/client-runtime/state/thread-sort"; import { appAtomRegistry } from "../../state/atom-registry"; import { environmentServerConfigsAtom } from "../../state/server"; import { environmentThreadShells, threadEnvironment } from "../../state/threads"; import { useAtomCommand } from "../../state/use-atom-command"; +import { beginPendingThreadOrder, getPendingThreadOrder } from "../../state/thread-order"; +import { createPendingThreadOrder, createThreadMovePlanner } from "../threads/threadOrder"; +import { getThreadListV2OrderedSection } from "../threads/threadListV2"; /** Version skew: never send settle/unsettle to a server that predates them (capability defaults false on decode for older servers). */ @@ -222,7 +221,7 @@ export function useThreadListActions(): { readonly unsettleThread: (thread: EnvironmentThreadShell) => Promise; readonly pinThread: (thread: EnvironmentThreadShell) => Promise; readonly unpinThread: (thread: EnvironmentThreadShell) => Promise; - readonly movePinnedThread: ( + readonly moveThread: ( thread: EnvironmentThreadShell, direction: "up" | "down", ) => Promise; @@ -451,60 +450,75 @@ export function useThreadListActions(): { [updateThreadMetadata], ); - // Move up / Move down for the pinned block. Computed against the CANONICAL - // keyed pinned order (not the rendered list), so the move is valid even - // while search or a project scope filters rows: the same fractional-key - // scheme web dragging uses, one write to one thread per move (plus a - // one-time section materialization when legacy keyless pins are involved). + // Plan against the complete section so filtering does not change a move. const reorderPinnedMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); - // One move at a time: a second tap before the first write's event lands - // would plan from the same stale snapshot and silently collapse two moves - // into one — same double-dispatch guard as snoozeThread. - const movePinnedInFlightRef = useRef(false); - const movePinnedThread = useCallback( + const reorderActiveMutation = useAtomCommand(threadEnvironment.reorderActive, { + reportFailure: false, + }); + const moveThread = useCallback( async (thread: EnvironmentThreadShell, direction: "up" | "down") => { - if (movePinnedInFlightRef.current) return false; - if (!environmentSupportsPinReorder(thread.environmentId)) { + if (getPendingThreadOrder() !== null) return false; + const section = thread.pinnedAt != null ? "pinned" : "active"; + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const supportsReorder = (environmentId: EnvironmentThreadShell["environmentId"]) => { + const capabilities = configs.get(environmentId)?.environment.capabilities; + return section === "pinned" + ? capabilities?.threadPinReorder === true + : capabilities?.threadActiveReorder === true; + }; + if (!supportsReorder(thread.environmentId)) { Alert.alert( "Could not move thread", - "This environment's server does not support pinned reordering yet. Update the server to reorder pins.", + "This environment's server does not support reordering these threads. Update the server to arrange them.", ); return false; } const shells = appAtomRegistry.get(environmentThreadShells.threadShellsAtom); - const pinned = sortPinnedThreadsByOrderKey( - shells.filter( - (shell) => - shell.pinnedAt != null && - shell.archivedAt === null && - environmentSupportsPinReorder(shell.environmentId), + const ordered = getThreadListV2OrderedSection({ + threads: shells, + section, + now: new Date().toISOString(), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], + ), ), - ); - const orderedIds = pinned.map((shell) => scopedThreadKey(shell.environmentId, shell.id)); - const assignments = planPinnedMove({ - orderedIds, - keysById: new Map( - pinned.map((shell) => [ - scopedThreadKey(shell.environmentId, shell.id), - shell.pinOrderKey ?? null, - ]), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], + ), ), - movedId: scopedThreadKey(thread.environmentId, thread.id), - direction, }); - if (assignments === null || assignments.length === 0) return false; + const assignments = createThreadMovePlanner({ + allThreads: shells, + ordered, + section, + reorderableEnvironmentIds: new Set([...configs.keys()].filter(supportsReorder)), + })(scopedThreadKey(thread.environmentId, thread.id), direction); + if (assignments === null) return false; const shellByKey = new Map( - pinned.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), + ordered.map((shell) => [scopedThreadKey(shell.environmentId, shell.id), shell]), ); selectionHaptic(); - movePinnedInFlightRef.current = true; + const pending = beginPendingThreadOrder( + createPendingThreadOrder({ + section, + ordered, + movedId: scopedThreadKey(thread.environmentId, thread.id), + direction, + assignments, + }), + ); + let succeeded = false; + const reorder = section === "pinned" ? reorderPinnedMutation : reorderActiveMutation; try { for (const assignment of assignments) { + if (!pending.isPending()) return false; const target = shellByKey.get(assignment.id); if (target === undefined) continue; - const result = await reorderPinnedMutation({ + const result = await reorder({ environmentId: target.environmentId, input: { threadId: target.id, orderKey: assignment.orderKey }, }); @@ -514,20 +528,20 @@ export function useThreadListActions(): { "Could not move thread", error instanceof Error && error.message.trim().length > 0 ? error.message - : "The pinned thread could not be moved.", + : "The thread could not be moved.", ); - // No rollback: keys already written are valid orderings on their - // own (each write is a complete, consistent placement), so a - // partial materialization leaves the list sensible, not corrupt. + // Keep confirmed keys when a later environment rejects its write. return false; } } + succeeded = true; + pending.complete(); return true; } finally { - movePinnedInFlightRef.current = false; + if (!succeeded) pending.cancel(); } }, - [reorderPinnedMutation], + [reorderActiveMutation, reorderPinnedMutation], ); const confirmDeleteThread = useConfirmDeleteThread(executeAction); @@ -541,7 +555,7 @@ export function useThreadListActions(): { unsettleThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, }; } diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index f529432070bf..12f5ac8ce4f3 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,3 +1,4 @@ +import { createThreadMovePlanner } from "./threadOrder"; import type { EnvironmentProject, EnvironmentThreadShell, @@ -10,7 +11,6 @@ import { LegendList } from "@legendapp/list/react-native"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { type EnvironmentId, resolveEnvironmentMachineKind } from "@t3tools/contracts"; -import { sortPinnedThreadsByOrderKey } from "@t3tools/client-runtime/state/thread-sort"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { LayoutChangeEvent } from "react-native"; import { Platform, Pressable, StyleSheet, TextInput, View } from "react-native"; @@ -30,6 +30,7 @@ import { useProjects, useThreadShells } from "../../state/entities"; import { useThreadSearch } from "../../state/queries"; import { useThreadListV2Enabled } from "./use-thread-list-v2-enabled"; import { useThreadListV2ShelfPreferences } from "./use-thread-list-v2-shelf-preferences"; +import { usePendingThreadOrder } from "../../state/thread-order"; import { environmentServerConfigsAtom } from "../../state/server"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -78,6 +79,7 @@ import { } from "./thread-list-v2-items"; import { buildThreadListV2Items, + getThreadListV2OrderedSection, buildThreadListV2ListItems, THREAD_LIST_V2_SETTLED_INITIAL_COUNT, THREAD_LIST_V2_SETTLED_PAGE_COUNT, @@ -158,7 +160,7 @@ function ThreadNavigationSidebarPane( unsettleThread, pinThread, unpinThread, - movePinnedThread, + moveThread, regenerateThreadTitle, } = useThreadListActions(); const threadListV2Enabled = useThreadListV2Enabled(); @@ -437,6 +439,15 @@ function ThreadNavigationSidebarPane( } return supported; }, [serverConfigs]); + const activeReorderEnvironmentIds = useMemo(() => { + const supported = new Set(); + for (const [environmentId, config] of serverConfigs) { + if (config.environment.capabilities.threadActiveReorder === true) { + supported.add(environmentId); + } + } + return supported; + }, [serverConfigs]); const titleRegenerationEnvironmentIds = useMemo(() => { const supported = new Set(); for (const [environmentId, config] of serverConfigs) { @@ -456,19 +467,40 @@ function ThreadNavigationSidebarPane( ), [serverConfigs], ); - // Canonical arranged pinned order for Move up/down flags — computed from - // all shells so search/scope filtering never disables a valid move. - const arrangedPinnedKeys = useMemo(() => { - const pinned = sortPinnedThreadsByOrderKey( - threads.filter( - (thread) => - thread.pinnedAt != null && - thread.archivedAt === null && - pinReorderEnvironmentIds.has(thread.environmentId), - ), - ); - return pinned.map((thread) => `${thread.environmentId}:${thread.id}`); - }, [pinReorderEnvironmentIds, threads]); + const pendingOrder = usePendingThreadOrder(nowMinute, snoozeWakeTick); + const threadMovePlanners = useMemo(() => { + const sectionPlanner = (section: "pinned" | "active") => + createThreadMovePlanner({ + allThreads: threads, + section, + reorderableEnvironmentIds: new Set( + [...serverConfigs].flatMap(([id, config]) => + (section === "pinned" + ? config.environment.capabilities.threadPinReorder + : config.environment.capabilities.threadActiveReorder) === true + ? [id] + : [], + ), + ), + ordered: getThreadListV2OrderedSection({ + threads, + section, + pendingOrder, + now: new Date().toISOString(), + settlementEnvironmentIds, + snoozeEnvironmentIds, + }), + }); + return { pinned: sectionPlanner("pinned"), active: sectionPlanner("active") }; + }, [ + serverConfigs, + threads, + pendingOrder, + settlementEnvironmentIds, + snoozeEnvironmentIds, + nowMinute, + snoozeWakeTick, + ]); const threadListV2Layout = useMemo(() => { if (!threadListV2Enabled) return { @@ -481,6 +513,7 @@ function ThreadNavigationSidebarPane( nextSnoozeWakeAt: null, }; return buildThreadListV2Items({ + pendingOrder, threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, @@ -495,6 +528,7 @@ function ThreadNavigationSidebarPane( selectedThreadKey: props.selectedThreadKey ?? null, }); }, [ + pendingOrder, nowMinute, snoozeWakeTick, snoozedShelfExpanded, @@ -844,6 +878,10 @@ function ThreadNavigationSidebarPane( } case "v2-thread": { const thread = item.item.thread; + const movePlanner = item.item.pinned + ? threadMovePlanners.pinned + : threadMovePlanners.active; + const movedId = `${thread.environmentId}:${thread.id}`; const scopeKey = scopedProjectKey(thread.environmentId, thread.projectId); return ( 0 + reorderSupported={ + item.item.pinned + ? pinReorderEnvironmentIds.has(thread.environmentId) + : activeReorderEnvironmentIds.has(thread.environmentId) } - canMovePinnedDown={(() => { - const index = arrangedPinnedKeys.indexOf(`${thread.environmentId}:${thread.id}`); - return index !== -1 && index < arrangedPinnedKeys.length - 1; - })()} + canMoveUp={pendingOrder === null && movePlanner(movedId, "up") !== null} + canMoveDown={pendingOrder === null && movePlanner(movedId, "down") !== null} onSnoozeThread={snoozeThread} onUnsnoozeThread={unsnoozeThread} onUnsettleThread={unsettleThread} onPinThread={pinThread} onUnpinThread={unpinThread} - onMovePinnedThread={movePinnedThread} + onMoveThread={moveThread} onSwipeableClose={handleSwipeableClose} onSwipeableWillOpen={handleSwipeableWillOpen} simultaneousSwipeGesture={sidebarScrollGesture} @@ -1027,14 +1064,16 @@ function ThreadNavigationSidebarPane( }, [ archiveThread, - arrangedPinnedKeys, + activeReorderEnvironmentIds, + threadMovePlanners, + pendingOrder, confirmDeletePendingTask, confirmDeleteThread, handleSelectThread, handleSwipeableClose, handleSwipeableWillOpen, machineByEnvironmentId, - movePinnedThread, + moveThread, openPendingTask, pinReorderEnvironmentIds, pinThread, 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 e66fa778476b..37b211e835f9 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -367,14 +367,13 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly pinningSupported: boolean; /** False on servers that predate thread title regeneration. */ readonly titleRegenerationSupported: boolean; - /** False on servers that predate thread.pin.reorder. Gates the pinned - Move up / Move down menu items. */ - readonly pinReorderSupported?: boolean; - readonly onMovePinnedThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; - /** Position flags for the pinned block so the menu disables the move that + /** Server supports reordering this card's section. */ + readonly reorderSupported?: boolean; + readonly onMoveThread?: (thread: EnvironmentThreadShell, direction: "up" | "down") => void; + /** Position flags for the card's section so the menu disables the move that would fall off the end of the list. */ - readonly canMovePinnedUp?: boolean; - readonly canMovePinnedDown?: boolean; + readonly canMoveUp?: boolean; + readonly canMoveDown?: boolean; readonly onSwipeableWillOpen: (methods: SwipeableMethods) => void; readonly onSwipeableClose: (methods: SwipeableMethods) => void; readonly searchMatch?: EnvironmentThreadSearchMatch; @@ -397,7 +396,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { onArchiveThread, onPinThread, onUnpinThread, - onMovePinnedThread, + onMoveThread, } = props; const snoozedRow = props.snoozed === true; const pinnedRow = props.pinned === true; @@ -436,14 +435,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const handleUnsettle = useCallback(() => onUnsettleThread(thread), [onUnsettleThread, thread]); const handlePin = useCallback(() => onPinThread(thread), [onPinThread, thread]); const handleUnpin = useCallback(() => onUnpinThread(thread), [onUnpinThread, thread]); - const handleMovePinnedUp = useCallback( - () => onMovePinnedThread?.(thread, "up"), - [onMovePinnedThread, thread], - ); - const handleMovePinnedDown = useCallback( - () => onMovePinnedThread?.(thread, "down"), - [onMovePinnedThread, thread], - ); + const handleMoveUp = useCallback(() => onMoveThread?.(thread, "up"), [onMoveThread, thread]); + const handleMoveDown = useCallback(() => onMoveThread?.(thread, "down"), [onMoveThread, thread]); const handleArchive = useCallback(() => onArchiveThread(thread), [onArchiveThread, thread]); // Swipe: the v2 primary action is the lifecycle transition. Un-settling a @@ -482,38 +475,39 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { // Pinned cards keep the full lifecycle menu; only the pin item flips to // Unpin. (Settling a pinned thread clears the pin server-side; snoozing // hides the card until wake with the pin intact.) - const pinMenuItem = useMemo( - () => - props.pinningSupported + const arrangementMenuItems = useMemo( + () => [ + ...(variant === "card" && props.reorderSupported === true + ? [ + { + id: "move-up", + title: "Move up", + image: "arrow.up", + attributes: { disabled: props.canMoveUp !== true }, + } satisfies MenuAction, + { + id: "move-down", + title: "Move down", + image: "arrow.down", + attributes: { disabled: props.canMoveDown !== true }, + } satisfies MenuAction, + ] + : []), + ...(props.pinningSupported ? [ - ...(pinnedRow && props.pinReorderSupported === true - ? [ - { - id: "move-pin-up", - title: "Move up", - image: "arrow.up", - attributes: { disabled: props.canMovePinnedUp !== true }, - } satisfies MenuAction, - { - id: "move-pin-down", - title: "Move down", - image: "arrow.down", - attributes: { disabled: props.canMovePinnedDown !== true }, - } satisfies MenuAction, - ] - : []), thread.pinnedAt != null ? { id: "unpin", title: "Unpin", image: "pin.slash" } : { id: "pin", title: "Pin", image: "pin" }, ] - : [], + : []), + ], [ - pinnedRow, - props.canMovePinnedDown, - props.canMovePinnedUp, - props.pinReorderSupported, + props.canMoveDown, + props.canMoveUp, + props.reorderSupported, props.pinningSupported, thread.pinnedAt, + variant, ], ); const titleRegenerationMenuItems = useMemo( @@ -533,37 +527,42 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { image: "clock", subactions: snoozePresetActions, }, - ...pinMenuItem, + ...arrangementMenuItems, ...titleRegenerationMenuItems, { id: "delete", title: "Delete", image: "trash", attributes: { destructive: true } }, ], - [pinMenuItem, snoozePresetActions, titleRegenerationMenuItems], + [arrangementMenuItems, snoozePresetActions, titleRegenerationMenuItems], ); const cardMenuActions = useMemo( () => [ CARD_MENU_ACTIONS[0]!, - ...pinMenuItem, + ...arrangementMenuItems, ...titleRegenerationMenuItems, ...CARD_MENU_ACTIONS.slice(1), ], - [pinMenuItem, titleRegenerationMenuItems], + [arrangementMenuItems, titleRegenerationMenuItems], ); const slimMenuActions = useMemo( () => [ SLIM_MENU_ACTIONS[0]!, - ...(thread.pinnedAt != null ? pinMenuItem : []), + ...(thread.pinnedAt != null ? arrangementMenuItems : []), ...titleRegenerationMenuItems, SLIM_MENU_ACTIONS[1]!, ], - [pinMenuItem, thread.pinnedAt, titleRegenerationMenuItems], + [arrangementMenuItems, thread.pinnedAt, titleRegenerationMenuItems], ); const snoozedMenuActions = useMemo( () => [SNOOZED_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, SNOOZED_MENU_ACTIONS[1]!], [titleRegenerationMenuItems], ); const legacyMenuActions = useMemo( - () => [LEGACY_MENU_ACTIONS[0]!, ...titleRegenerationMenuItems, LEGACY_MENU_ACTIONS[1]!], - [titleRegenerationMenuItems], + () => [ + LEGACY_MENU_ACTIONS[0]!, + ...arrangementMenuItems, + ...titleRegenerationMenuItems, + LEGACY_MENU_ACTIONS[1]!, + ], + [arrangementMenuItems, titleRegenerationMenuItems], ); const handleMenuAction = useCallback( ({ nativeEvent }: { readonly nativeEvent: { readonly event: string } }) => { @@ -572,8 +571,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { if (nativeEvent.event === "unsnooze") handleUnsnooze(); if (nativeEvent.event === "pin") handlePin(); if (nativeEvent.event === "unpin") handleUnpin(); - if (nativeEvent.event === "move-pin-up") handleMovePinnedUp(); - if (nativeEvent.event === "move-pin-down") handleMovePinnedDown(); + if (nativeEvent.event === "move-up") handleMoveUp(); + if (nativeEvent.event === "move-down") handleMoveDown(); if (nativeEvent.event === "archive") handleArchive(); if (nativeEvent.event === "regenerate-title") handleRegenerateTitle(); if (nativeEvent.event === "delete") handleDelete(); @@ -592,8 +591,8 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { handleArchive, handleDelete, handleRegenerateTitle, - handleMovePinnedDown, - handleMovePinnedUp, + handleMoveDown, + handleMoveUp, handlePin, handleSettle, handleSnooze, diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 33ae27cc0638..4b1abb78c272 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -1,3 +1,10 @@ +import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import { + createPendingThreadOrder, + createThreadMovePlanner, + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "./threadOrder"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; @@ -16,6 +23,7 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { buildThreadListV2Items, buildThreadListV2ListItems, + getThreadListV2OrderedSection, resolveThreadListV2Enabled, resolveThreadListV2SnoozeMenuSelection, resolveThreadListV2SnoozeGateExpiryMs, @@ -258,6 +266,15 @@ describe("resolveThreadListV2SnoozeGateExpiryMs", () => { }); describe("sortThreadsForListV2", () => { + it("honors a saved active order and leaves new threads above it", () => { + const sorted = sortThreadsForListV2([ + { id: "newer-arranged", createdAt: "2026-06-01T12:00:00.000Z", activeOrderKey: "t" }, + { id: "older-arranged", createdAt: "2026-06-01T08:00:00.000Z", activeOrderKey: "f" }, + { id: "new", createdAt: "2026-06-01T13:00:00.000Z" }, + ]); + expect(sorted.map((thread) => thread.id)).toEqual(["new", "older-arranged", "newer-arranged"]); + }); + it("orders by creation time, newest first, ignoring activity", () => { const sorted = sortThreadsForListV2([ { id: "oldest", createdAt: "2026-06-01T08:00:00.000Z" }, @@ -281,6 +298,55 @@ describe("sortThreadsForListV2", () => { }); }); +describe("getThreadListV2OrderedSection", () => { + it("uses each saved order and excludes settled, snoozed, and archived rows", () => { + const threads = [ + makeThread({ id: ThreadId.make("active-later"), title: "Later", activeOrderKey: "t" }), + makeThread({ id: ThreadId.make("active-first"), title: "First", activeOrderKey: "f" }), + makeThread({ id: ThreadId.make("active-new"), title: "New" }), + makeThread({ + id: ThreadId.make("pinned-later"), + title: "Pinned later", + pinnedAt: NOW, + pinOrderKey: "t", + activeOrderKey: "f", + }), + makeThread({ + id: ThreadId.make("pinned-first"), + title: "Pinned first", + pinnedAt: NOW, + pinOrderKey: "f", + activeOrderKey: "t", + }), + makeThread({ id: ThreadId.make("settled"), title: "Settled", settledOverride: "settled" }), + makeThread({ id: ThreadId.make("archived"), title: "Archived", archivedAt: NOW }), + makeThread({ + id: ThreadId.make("snoozed"), + title: "Snoozed", + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: NOW, + }), + makeThread({ + id: ThreadId.make("pinned-snoozed"), + title: "Pinned snoozed", + pinnedAt: NOW, + snoozedUntil: "2026-06-03T10:00:00.000Z", + snoozedAt: NOW, + }), + ]; + expect( + getThreadListV2OrderedSection({ threads, section: "active", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["active-new", "active-first", "active-later"]); + expect( + getThreadListV2OrderedSection({ threads, section: "pinned", now: NOW }).map( + (thread) => thread.id, + ), + ).toEqual(["pinned-first", "pinned-later"]); + }); +}); + describe("buildThreadListV2Items", () => { it("places a persisted settled thread in the settled shelf", () => { const thread = makeThread({ @@ -937,3 +1003,230 @@ describe("buildThreadListV2ListItems", () => { ]); }); }); + +describe("pending mobile thread moves", () => { + function fixture(section: "active" | "pinned" = "active") { + const rows = ["a", "b", "c"].map((id, index) => + makeThread({ + id: ThreadId.make(id), + title: id === "a" ? "hidden" : "match", + createdAt: `2026-06-01T0${3 - index}:00:00.000Z`, + pinnedAt: section === "pinned" ? `2026-06-01T0${3 - index}:00:00.000Z` : null, + }), + ); + const ordered = getThreadListV2OrderedSection({ threads: rows, section, now: NOW }); + const orderedIds = ordered.map((row) => `${row.environmentId}:${row.id}`); + const movedId = orderedIds[2]!; + const assignments = planPinnedMove({ + orderedIds, + keysById: new Map(orderedIds.map((id) => [id, null])), + movedId, + direction: "up", + })!; + const pending = createPendingThreadOrder({ + section, + ordered, + movedId, + direction: "up", + assignments, + }); + const update = (current: EnvironmentThreadShell[], assignment: (typeof assignments)[number]) => + current.map((row) => + `${row.environmentId}:${row.id}` === assignment.id + ? { + ...row, + [section === "pinned" ? "pinOrderKey" : "activeOrderKey"]: assignment.orderKey, + } + : row, + ); + return { rows, assignments, pending, update }; + } + + function layout( + rows: EnvironmentThreadShell[], + pendingOrder: PendingThreadOrder | null, + searchQuery = "", + ) { + return buildThreadListV2Items({ + threads: rows, + pendingOrder, + environmentId: null, + searchQuery, + now: NOW, + }).items.map((item) => item.thread.id); + } + + it.each(["active", "pinned"] as const)( + "holds %s order through every intermediate key upsert", + (section) => { + const { rows, assignments, pending, update } = fixture(section); + let current = rows; + let hold: PendingThreadOrder | null = pending; + const desired = pending.orderedIds.map((id) => id.split(":")[1]); + expect(layout(current, hold)).toEqual(desired); + for (const assignment of assignments) { + current = update(current, assignment); + hold = reconcilePendingThreadOrder( + hold!, + getThreadListV2OrderedSection({ threads: current, section, now: NOW }), + ); + expect(hold).not.toBeNull(); + expect(layout(current, hold)).toEqual(desired); + } + expect(reconcilePendingThreadOrder({ ...hold!, commandsComplete: true }, current)).toBeNull(); + expect(layout(current, null)).toEqual(desired); + }, + ); + + it("keeps the action guard pending when receipts precede canonical shells", () => { + const { rows, assignments, pending, update } = fixture(); + let hold: PendingThreadOrder | null = { ...pending, commandsComplete: true }; + let current = rows; + expect(reconcilePendingThreadOrder(hold, current)).toBe(hold); + for (const [index, assignment] of assignments.entries()) { + current = update(current, assignment); + hold = reconcilePendingThreadOrder(hold!, current); + expect(hold === null).toBe(index === assignments.length - 1); + expect(layout(current, hold)).toEqual(["a", "c", "b"]); + } + }); + + it("keeps search results in the full pending section order", () => { + const { rows, assignments, pending, update } = fixture(); + const current = update(update(rows, assignments[0]!), assignments[1]!); + expect(layout(current, pending, "match")).toEqual(["c", "b"]); + }); + + it("releases for real section membership and foreign key changes", () => { + const { rows, pending } = fixture(); + expect(reconcilePendingThreadOrder(pending, rows.slice(1))).toBeNull(); + const newRow = makeThread({ id: ThreadId.make("new"), title: "new" }); + expect(reconcilePendingThreadOrder(pending, [...rows, newRow])).toBeNull(); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row, index) => (index === 0 ? { ...row, activeOrderKey: "zz" } : row)), + ), + ).toBeNull(); + const settled = rows.map((row, index) => + index === 0 ? { ...row, settledOverride: "settled" as const } : row, + ); + expect(layout(settled, pending)).toEqual(layout(settled, null)); + }); + + it("does not hide a concurrent return to a previously confirmed key", () => { + const { rows, assignments, pending, update } = fixture(); + const confirmed = reconcilePendingThreadOrder(pending, update(rows, assignments[0]!))!; + expect(reconcilePendingThreadOrder(confirmed, rows)).toBeNull(); + }); + + it("preserves the hold for activity but releases for a reopened sort anchor", () => { + const { rows, pending } = fixture(); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row) => ({ ...row, updatedAt: NOW })), + ), + ).toBe(pending); + expect( + reconcilePendingThreadOrder( + pending, + rows.map((row, index) => (index === 0 ? { ...row, unsettledAt: NOW } : row)), + ), + ).toBeNull(); + }); +}); + +describe("mobile move availability", () => { + const oldEnvironment = EnvironmentId.make("older-server"); + function rows(section: "active" | "pinned", keys: readonly (string | null)[]) { + return keys.map((key, index) => + makeThread({ + id: ThreadId.make(`move-${index}`), + title: `Move ${index}`, + environmentId: index === 1 ? oldEnvironment : environmentId, + activeOrderKey: section === "active" ? key : null, + pinOrderKey: section === "pinned" ? key : null, + pinnedAt: section === "pinned" ? NOW : null, + }), + ); + } + + it.each(["active", "pinned"] as const)( + "keeps unsupported keyed %s neighbors as usable anchors", + (section) => { + const ordered = rows(section, ["bb", "dd", "ff"]); + const plan = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + }); + const assignments = plan(`${environmentId}:move-0`, "down"); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(`${environmentId}:move-0`); + expect(assignments![0]!.orderKey > "dd").toBe(true); + expect(assignments![0]!.orderKey < "ff").toBe(true); + expect(plan(`${oldEnvironment}:move-1`, "up")).toBeNull(); + expect(plan(`${environmentId}:move-0`, "up")).toBeNull(); + }, + ); + + it.each(["active", "pinned"] as const)( + "disables %s moves requiring unsupported keyless materialization", + (section) => { + const ordered = rows(section, [null, null, null]); + const plan = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId]), + }); + expect(plan(`${environmentId}:move-0`, "down")).toBeNull(); + expect(plan(`${environmentId}:move-2`, "up")).toBeNull(); + const supported = createThreadMovePlanner({ + ordered, + section, + reorderableEnvironmentIds: new Set([environmentId, oldEnvironment]), + }); + expect(supported(`${environmentId}:move-0`, "down")).toHaveLength(3); + }, + ); + + it.each(["active", "pinned"] as const)( + "reserves snoozed %s keys when moving visible rows", + (section) => { + const ordered = rows(section, ["bb", "dd", "ff"]); + const input = { ordered, section, reorderableEnvironmentIds: new Set([environmentId]) }; + const collision = createThreadMovePlanner(input)(`${environmentId}:move-0`, "down")![0]! + .orderKey; + const hidden = { + ...ordered[0]!, + id: ThreadId.make("snoozed"), + snoozedAt: NOW, + snoozedUntil: "2099-01-01T00:00:00.000Z", + pinOrderKey: section === "pinned" ? collision : null, + activeOrderKey: section === "active" ? collision : null, + }; + const assignments = createThreadMovePlanner({ ...input, allThreads: [...ordered, hidden] })( + `${environmentId}:move-0`, + "down", + ); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.orderKey).not.toBe(collision); + expect(assignments![0]!.orderKey > "dd" && assignments![0]!.orderKey < "ff").toBe(true); + }, + ); + + it("allows an independent keyed move despite an unsupported keyless row elsewhere", () => { + const ordered = rows("active", [null, null, "bb", "dd", "ff"]); + const plan = createThreadMovePlanner({ + ordered, + section: "active", + reorderableEnvironmentIds: new Set([environmentId]), + }); + const assignments = plan(`${environmentId}:move-4`, "up"); + expect(assignments).toHaveLength(1); + expect(assignments![0]!.id).toBe(`${environmentId}:move-4`); + expect(assignments![0]!.orderKey > "bb").toBe(true); + expect(assignments![0]!.orderKey < "dd").toBe(true); + }); +}); diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index 2b44851f9309..7b6b44c00bdf 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -9,7 +9,7 @@ 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, + sortActiveThreadsByOrderKey, resolveSettledThreadTimestamp, sortPinnedThreadsByOrderKey, } from "@t3tools/client-runtime/state/thread-sort"; @@ -17,6 +17,12 @@ import type { EnvironmentId, ProjectId } from "@t3tools/contracts"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; +import { + applyPendingThreadOrder, + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "./threadOrder"; + export { snoozeWakeLabel }; /** @@ -150,28 +156,54 @@ function parseTimestampMs(isoDate: string): number { return Number.isNaN(parsed) ? 0 : parsed; } -/** - * v2 sort: static order, newest anchor on top. Activity NEVER reorders the - * list — a row holds its position between lifecycle transitions. The anchor - * is creation time until an un-settle re-anchors it (see - * activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the - * top instead of sinking back to its creation-order slot. Mirrors web's - * sortThreadsForSidebar. - */ +/** The active order shared by web and native: new/reopened rows, then the + saved arrangement. Activity does not move a thread. */ export function sortThreadsForListV2< T extends { readonly id: string; readonly createdAt: string; readonly unsettledAt?: string | null | undefined; + readonly activeOrderKey?: string | null | undefined; + readonly environmentId?: string | undefined; }, >(threads: readonly T[]): T[] { - // .sort() on a copy, not .toSorted(): Hermes doesn't ship the ES2023 - // change-by-copy array methods. - return [...threads].sort( - (left, right) => - activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || - left.id.localeCompare(right.id), - ); + return sortActiveThreadsByOrderKey(threads); +} + +/** Canonical card section for Move up/down, independent of search or scope. */ +export function getThreadListV2OrderedSection(input: { + readonly threads: readonly EnvironmentThreadShell[]; + readonly section: "pinned" | "active"; + readonly pendingOrder?: PendingThreadOrder | null; + readonly now: string; + readonly settlementEnvironmentIds?: ReadonlySet; + readonly snoozeEnvironmentIds?: ReadonlySet; +}): EnvironmentThreadShell[] { + const threads = input.threads.filter((thread) => { + if (thread.archivedAt !== null) return false; + if ( + (input.settlementEnvironmentIds?.has(thread.environmentId) ?? true) && + thread.settledOverride === "settled" + ) { + return false; + } + if ( + (input.snoozeEnvironmentIds?.has(thread.environmentId) ?? true) && + effectiveSnoozed(thread, { now: input.now }) + ) { + return false; + } + return (thread.pinnedAt != null) === (input.section === "pinned"); + }); + const ordered = + input.section === "pinned" + ? sortPinnedThreadsByOrderKey(threads) + : sortActiveThreadsByOrderKey(threads); + const pending = + input.pendingOrder?.section === input.section + ? reconcilePendingThreadOrder(input.pendingOrder, ordered) + : null; + return applyPendingThreadOrder(ordered, input.section, pending); } export interface ThreadListV2Item { @@ -299,10 +331,11 @@ export function buildThreadListV2ListItems(input: { } /** - * Partitions visible threads into the active card block (creation order) and + * Partitions visible threads into the active card block (saved order) and * the settled recency tail, matching the web v2 list. */ export function buildThreadListV2Items(input: { + readonly pendingOrder?: PendingThreadOrder | null; readonly threads: ReadonlyArray; readonly environmentId: EnvironmentId | null; readonly projectRefs?: ReadonlyArray<{ @@ -331,6 +364,17 @@ export function buildThreadListV2Items(input: { readonly selectedThreadKey?: string | null; }): ThreadListV2Layout { const now = input.now; + const pending = + input.pendingOrder == null + ? null + : reconcilePendingThreadOrder( + input.pendingOrder, + getThreadListV2OrderedSection({ + ...input, + section: input.pendingOrder.section, + pendingOrder: null, + }), + ); const query = input.searchQuery.trim().toLocaleLowerCase(); const projectKeys = input.projectRefs ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) @@ -382,7 +426,7 @@ export function buildThreadListV2Items(input: { } } - const orderedActive = sortThreadsForListV2(active); + const orderedActive = applyPendingThreadOrder(sortThreadsForListV2(active), "active", pending); const orderedSnoozed = [...snoozed].sort( (left, right) => parseTimestampMs(left.snoozedUntil ?? "") - parseTimestampMs(right.snoozedUntil ?? ""), @@ -414,7 +458,11 @@ export function buildThreadListV2Items(input: { ); const items: ThreadListV2Item[] = []; - for (const thread of sortPinnedThreadsByOrderKey(pinned)) { + for (const thread of applyPendingThreadOrder( + sortPinnedThreadsByOrderKey(pinned), + "pinned", + pending, + )) { items.push({ thread, variant: "card", diff --git a/apps/mobile/src/features/threads/threadOrder.ts b/apps/mobile/src/features/threads/threadOrder.ts new file mode 100644 index 000000000000..2b722e69428b --- /dev/null +++ b/apps/mobile/src/features/threads/threadOrder.ts @@ -0,0 +1,120 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { planPinnedMove } from "@t3tools/client-runtime/state/thread-sort"; +import type { EnvironmentId } from "@t3tools/contracts"; + +type OrderRow = Pick< + EnvironmentThreadShell, + | "id" + | "environmentId" + | "pinOrderKey" + | "activeOrderKey" + | "createdAt" + | "unsettledAt" + | "pinnedAt" +>; + +export interface PendingThreadOrder { + readonly section: "pinned" | "active"; + readonly orderedIds: readonly string[]; + readonly before: ReadonlyMap; + readonly assignments: ReadonlyMap; + readonly confirmed: ReadonlySet; + readonly commandsComplete: boolean; +} + +function rowId(row: OrderRow): string { + return `${row.environmentId}:${row.id}`; +} + +function rowOrder(row: OrderRow, section: PendingThreadOrder["section"]) { + return { + key: (section === "pinned" ? row.pinOrderKey : row.activeOrderKey) ?? null, + anchor: section === "pinned" ? (row.pinnedAt ?? "") : (row.unsettledAt ?? row.createdAt), + }; +} + +/** Keep every visible row as an anchor, but only offer plans whose key writes + * are supported. Menu availability and execution use this same planner. */ +export function createThreadMovePlanner(input: { + readonly ordered: readonly OrderRow[]; + readonly allThreads?: readonly OrderRow[]; + readonly section: PendingThreadOrder["section"]; + readonly reorderableEnvironmentIds: ReadonlySet; +}) { + const orderedIds = input.ordered.map(rowId); + const keysById = new Map( + (input.allThreads ?? input.ordered).map((row) => [ + rowId(row), + rowOrder(row, input.section).key, + ]), + ); + const writableIds = new Set( + input.ordered + .filter((row) => input.reorderableEnvironmentIds.has(row.environmentId)) + .map(rowId), + ); + return (movedId: string, direction: "up" | "down") => { + if (!writableIds.has(movedId)) return null; + const assignments = planPinnedMove({ orderedIds, keysById, movedId, direction }); + return assignments === null || + assignments.length === 0 || + assignments.some((assignment) => !writableIds.has(assignment.id)) + ? null + : assignments; + }; +} + +export function createPendingThreadOrder(input: { + readonly section: PendingThreadOrder["section"]; + readonly ordered: readonly OrderRow[]; + readonly movedId: string; + readonly direction: "up" | "down"; + readonly assignments: readonly { readonly id: string; readonly orderKey: string }[]; +}): PendingThreadOrder { + const orderedIds = input.ordered.map(rowId); + const from = orderedIds.indexOf(input.movedId); + orderedIds.splice(from, 1); + orderedIds.splice(from + (input.direction === "up" ? -1 : 1), 0, input.movedId); + return { + section: input.section, + orderedIds, + before: new Map(input.ordered.map((row) => [rowId(row), rowOrder(row, input.section)])), + assignments: new Map(input.assignments.map(({ id, orderKey }) => [id, orderKey])), + confirmed: new Set(), + commandsComplete: false, + }; +} + +/** Receipts and shell updates arrive independently. Only our own key writes + * may pass through the hold; membership and other arrangement changes win. */ +export function reconcilePendingThreadOrder( + pending: PendingThreadOrder, + ordered: readonly OrderRow[], +): PendingThreadOrder | null { + if (ordered.length !== pending.before.size) return null; + const confirmed = new Set(pending.confirmed); + for (const row of ordered) { + const id = rowId(row); + const before = pending.before.get(id); + const current = rowOrder(row, pending.section); + if (before === undefined || current.anchor !== before.anchor) return null; + const assigned = pending.assignments.get(id); + if (assigned !== undefined && current.key === assigned) confirmed.add(id); + else if (current.key !== before.key || confirmed.has(id)) return null; + } + if (pending.commandsComplete && confirmed.size === pending.assignments.size) return null; + return confirmed.size === pending.confirmed.size ? pending : { ...pending, confirmed }; +} + +/** Apply the full section's pending order after search/environment filtering. */ +export function applyPendingThreadOrder( + rows: readonly T[], + section: PendingThreadOrder["section"], + pending: PendingThreadOrder | null | undefined, +): T[] { + if (pending == null || pending.section !== section) return [...rows]; + const rank = new Map(pending.orderedIds.map((id, index) => [id, index])); + return [...rows].sort( + (left, right) => (rank.get(rowId(left)) ?? Infinity) - (rank.get(rowId(right)) ?? Infinity), + ); +} diff --git a/apps/mobile/src/state/thread-order.test.ts b/apps/mobile/src/state/thread-order.test.ts new file mode 100644 index 000000000000..4959ad989626 --- /dev/null +++ b/apps/mobile/src/state/thread-order.test.ts @@ -0,0 +1,117 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; +import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import type { Atom } from "effect/unstable/reactivity"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createPendingThreadOrder } from "../features/threads/threadOrder"; +import { appAtomRegistry } from "./atom-registry"; +import { + beginPendingThreadOrder, + getPendingThreadOrder, + pendingThreadOrderAtom, +} from "./thread-order"; +import { environmentThreadShells } from "./threads"; + +vi.mock("./atom-registry", async () => { + const { AtomRegistry } = await import("effect/unstable/reactivity"); + return { appAtomRegistry: AtomRegistry.make() }; +}); +vi.mock("./threads", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { environmentThreadShells: { threadShellsAtom: Atom.make([]).pipe(Atom.keepAlive) } }; +}); +vi.mock("./server", async () => { + const { Atom } = await import("effect/unstable/reactivity"); + return { environmentServerConfigsAtom: Atom.make(new Map()).pipe(Atom.keepAlive) }; +}); + +// The mocked shell source is writable so tests can deliver canonical upserts. +const shellsAtom = environmentThreadShells.threadShellsAtom as Atom.Writable< + readonly EnvironmentThreadShell[], + readonly EnvironmentThreadShell[] +>; + +function fixture() { + // Only section membership and order fields are read by this coordinator. + const rows = ["a", "b"].map( + (id, index) => + ({ + id: ThreadId.make(id), + environmentId: EnvironmentId.make("env"), + createdAt: `2026-06-01T0${2 - index}:00:00.000Z`, + archivedAt: null, + pinnedAt: null, + activeOrderKey: null, + }) as EnvironmentThreadShell, + ); + appAtomRegistry.set(shellsAtom, rows); + const pending = createPendingThreadOrder({ + section: "active", + ordered: rows, + movedId: "env:b", + direction: "up", + assignments: [ + { id: "env:b", orderKey: "aa" }, + { id: "env:a", orderKey: "bb" }, + ], + }); + const start = () => beginPendingThreadOrder(pending); + const upsert = (id: string, key: string) => { + const current = appAtomRegistry.get(shellsAtom); + appAtomRegistry.set( + shellsAtom, + current.map((row) => (row.id === id ? { ...row, activeOrderKey: key } : row)), + ); + }; + return { rows, start, upsert }; +} + +afterEach(() => appAtomRegistry.reset()); + +describe("shared mobile pending move", () => { + it("blocks another pickup after receipts and clears on final canonical upsert", () => { + const { start, upsert } = fixture(); + const move = start(); + move.complete(); + expect(getPendingThreadOrder()).not.toBeNull(); + upsert("b", "aa"); + expect(getPendingThreadOrder()).not.toBeNull(); + upsert("a", "bb"); + expect(getPendingThreadOrder()).toBeNull(); + expect(move.isPending()).toBe(false); + }); + + it("waits for receipts when shells arrive first", () => { + const { start, upsert } = fixture(); + const move = start(); + upsert("b", "aa"); + upsert("a", "bb"); + expect(getPendingThreadOrder()).not.toBeNull(); + move.complete(); + expect(getPendingThreadOrder()).toBeNull(); + }); + + it.each(["failure", "interruption"])("releases a %s without restoring old canonical keys", () => { + const { start, upsert } = fixture(); + const move = start(); + upsert("b", "aa"); + move.cancel(); + expect(getPendingThreadOrder()).toBeNull(); + expect(appAtomRegistry.get(shellsAtom)[1]?.activeOrderKey).toBe("aa"); + const next = start(); + move.cancel(); + expect(next.isPending()).toBe(true); + next.cancel(); + }); + + it("stops remaining writes when a canonical membership change invalidates the move", () => { + const { rows, start } = fixture(); + const move = start(); + appAtomRegistry.set(shellsAtom, rows.slice(1)); + expect(move.isPending()).toBe(false); + expect(appAtomRegistry.get(pendingThreadOrderAtom)).toBeNull(); + move.complete(); + appAtomRegistry.set(shellsAtom, rows); + expect(getPendingThreadOrder()).toBeNull(); + }); +}); diff --git a/apps/mobile/src/state/thread-order.ts b/apps/mobile/src/state/thread-order.ts new file mode 100644 index 000000000000..0fb57fc826e0 --- /dev/null +++ b/apps/mobile/src/state/thread-order.ts @@ -0,0 +1,89 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useEffect } from "react"; +import { Atom } from "effect/unstable/reactivity"; + +import { + reconcilePendingThreadOrder, + type PendingThreadOrder, +} from "../features/threads/threadOrder"; +import { getThreadListV2OrderedSection } from "../features/threads/threadListV2"; +import { appAtomRegistry } from "./atom-registry"; +import { environmentServerConfigsAtom } from "./server"; +import { environmentThreadShells } from "./threads"; + +export const pendingThreadOrderAtom = Atom.make(null).pipe( + Atom.keepAlive, +); + +export function usePendingThreadOrder(nowMinute: string, snoozeWakeTick: number) { + const pending = useAtomValue(pendingThreadOrderAtom); + // A timed wake can change section membership without a shell event. Use the + // lists' existing clocks to retire that hold and re-enable their move menus. + useEffect(() => { + getPendingThreadOrder(); + }, [nowMinute, snoozeWakeTick]); + return pending; +} + +let refreshPendingOrder: (() => void) | undefined; + +/** Shared by Home and the navigation sidebar, including their action guards. */ +export function getPendingThreadOrder(): PendingThreadOrder | null { + refreshPendingOrder?.(); + return appAtomRegistry.get(pendingThreadOrderAtom); +} + +export function beginPendingThreadOrder(pending: PendingThreadOrder) { + const unsubscribers: (() => void)[] = []; + const cancel = () => { + if (refreshPendingOrder !== refresh) return; + refreshPendingOrder = undefined; + for (const unsubscribe of unsubscribers) unsubscribe(); + appAtomRegistry.set(pendingThreadOrderAtom, null); + }; + const refresh = () => { + if (refreshPendingOrder !== refresh) return; + const current = appAtomRegistry.get(pendingThreadOrderAtom); + if (current === null) return; + const configs = appAtomRegistry.get(environmentServerConfigsAtom); + const ordered = getThreadListV2OrderedSection({ + threads: appAtomRegistry.get(environmentThreadShells.threadShellsAtom), + section: current.section, + now: new Date().toISOString(), + settlementEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSettlement === true ? [id] : [], + ), + ), + snoozeEnvironmentIds: new Set( + [...configs].flatMap(([id, config]) => + config.environment.capabilities.threadSnooze === true ? [id] : [], + ), + ), + }); + const next = reconcilePendingThreadOrder(current, ordered); + if (next === null) cancel(); + else if (next !== current) appAtomRegistry.set(pendingThreadOrderAtom, next); + }; + refreshPendingOrder = refresh; + appAtomRegistry.set(pendingThreadOrderAtom, pending); + unsubscribers.push( + appAtomRegistry.subscribe(environmentThreadShells.threadShellsAtom, refresh), + appAtomRegistry.subscribe(environmentServerConfigsAtom, refresh), + ); + return { + isPending: () => { + refresh(); + return refreshPendingOrder === refresh; + }, + complete: () => { + if (refreshPendingOrder !== refresh) return; + const current = appAtomRegistry.get(pendingThreadOrderAtom); + if (current !== null) { + appAtomRegistry.set(pendingThreadOrderAtom, { ...current, commandsComplete: true }); + refresh(); + } + }, + cancel, + }; +} diff --git a/apps/mobile/src/state/use-thread-selection.ts b/apps/mobile/src/state/use-thread-selection.ts index b7350dd5dddf..7e012cb78903 100644 --- a/apps/mobile/src/state/use-thread-selection.ts +++ b/apps/mobile/src/state/use-thread-selection.ts @@ -62,6 +62,10 @@ function threadDetailToShell( archivedAt: thread.archivedAt, settledOverride: thread.settledOverride, settledAt: thread.settledAt, + unsettledAt: thread.unsettledAt, + activeOrderKey: thread.activeOrderKey, + pinnedAt: thread.pinnedAt, + pinOrderKey: thread.pinOrderKey, snoozedUntil: thread.snoozedUntil ?? null, snoozedAt: thread.snoozedAt ?? null, session: thread.session, From 4023d93bce3f610349fbf38e991f737c1c5e0c98 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:14 -0700 Subject: [PATCH 227/320] feat(web): drag threads across sections with consistent motion (#9731) --- apps/web/src/components/Sidebar.drag.test.ts | 616 ++++++ apps/web/src/components/Sidebar.drag.ts | 175 ++ apps/web/src/components/Sidebar.logic.test.ts | 726 ++++++- apps/web/src/components/Sidebar.logic.ts | 276 ++- .../web/src/components/Sidebar.motion.test.ts | 339 +++ apps/web/src/components/Sidebar.motion.ts | 168 ++ apps/web/src/components/Sidebar.tsx | 1810 +++++++++++------ apps/web/src/hooks/useThreadActions.ts | 38 + apps/web/src/lib/threadSort.ts | 1 - apps/web/src/state/entities.ts | 7 + docs/user/thread-sidebar.md | 34 +- .../client-runtime/src/state/threadSort.ts | 2 +- 12 files changed, 3534 insertions(+), 658 deletions(-) create mode 100644 apps/web/src/components/Sidebar.drag.test.ts create mode 100644 apps/web/src/components/Sidebar.drag.ts create mode 100644 apps/web/src/components/Sidebar.motion.test.ts create mode 100644 apps/web/src/components/Sidebar.motion.ts diff --git a/apps/web/src/components/Sidebar.drag.test.ts b/apps/web/src/components/Sidebar.drag.test.ts new file mode 100644 index 000000000000..894fb7b4fee1 --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.test.ts @@ -0,0 +1,616 @@ +import { describe, expect, it, vi } from "vite-plus/test"; +import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; +import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; +import { + sidebarListItemId, + sidebarMarkerId, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, +} from "./Sidebar.logic"; + +const thread = (key: string, section: SidebarSection): SidebarListItem => ({ + kind: "thread", + key, + section, +}); +const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker }); +const pinnedHeader = marker("pinned-header"); +const divider = marker("pinned-divider"); +const settledHeader = marker("settled-header"); +const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; + +function layout( + items: readonly SidebarListItem[], + active: string, + over: string, + scale = 1, + cardHeight = 82, +) { + let top = 100; + const rects = items.map((item) => { + const height = + item.kind === "thread" + ? (item.section === "pinned" || item.section === "active" ? cardHeight : 36) * scale + : item.marker === "pinned-header" || item.marker === "pinned-divider" + ? 0 + : (item.marker.endsWith("placeholder") ? 36 : 32) * scale; + const rect = { top, height, bottom: top + height, left: 0, right: 260, width: 260 }; + top += height + 1; + return rect; + }); + const activeIndex = items.findIndex((item) => sidebarListItemId(item) === active); + return { + activeIndex, + overIndex: items.findIndex((item) => sidebarListItemId(item) === over), + activeNodeRect: rects[activeIndex]!, + rects, + index: 0, + } satisfies Parameters[0]; +} + +function preview( + input: Parameters[0], + active: string, + over: string, + scale = 1, +) { + const strategy = createSidebarSortingStrategy(input); + const args = layout(input.items, active, over, scale); + return new Map( + input.items.map((item, index) => [sidebarListItemId(item), strategy({ ...args, index })]), + ); +} + +describe("sidebar collision detection", () => { + function collisionArgs(blockedAboveSource = false) { + const rows = [thread("source", "active"), thread("blocked", "active")]; + const items = [ + pinnedHeader, + divider, + ...(blockedAboveSource ? rows.toReversed() : rows), + settledHeader, + marker("settled-placeholder"), + ]; + const { rects, activeIndex, overIndex } = layout(items, "source", "blocked"); + const collisionRect = rects[overIndex]!; + return { + active: { + id: "source", + data: { current: {} }, + rect: { current: { initial: rects[activeIndex]!, translated: collisionRect } }, + }, + collisionRect, + droppableRects: new Map(items.map((item, index) => [sidebarListItemId(item), rects[index]!])), + droppableContainers: items.map((item, index) => ({ + id: sidebarListItemId(item), + key: sidebarListItemId(item), + disabled: false, + data: { current: {} }, + node: { current: null }, + rect: { current: rects[index]! }, + })), + pointerCoordinates: null, + } satisfies Parameters[0]; + } + + it.each([ + [false, sidebarMarkerId("settled-header")], + [true, sidebarMarkerId("pinned-divider")], + ] as const)( + "rejects unsupported Active instead of selecting %s / %s", + (blockedAboveSource, nearbyTarget) => { + const args = collisionArgs(blockedAboveSource); + const detector = createSidebarCollisionDetection((id) => id !== "blocked"); + const filtered = closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => container.id !== "blocked", + ), + }); + expect(filtered[0]?.id).toBe(nearbyTarget); + expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); + }, + ); + + it("selects the nearest supported target", () => { + const detector = createSidebarCollisionDetection(() => true); + expect(detector(collisionArgs())[0]?.id).toBe("blocked"); + }); + + function clampedArgs() { + const args = collisionArgs(); + const pinned = args.droppableRects.get(sidebarMarkerId("pinned-header"))!; + const source = args.droppableRects.get("source")!; + const collisionRect = { + ...source, + top: pinned.top - 8, + bottom: pinned.top - 8 + source.height, + }; + return { + ...args, + active: { + ...args.active, + rect: { current: { initial: source, translated: collisionRect } }, + }, + collisionRect, + pointerCoordinates: { x: pinned.left + pinned.width / 2, y: pinned.top + 8 }, + }; + } + + it("reaches empty Pins with an upward pointer while the card is clamped at the top", () => { + const args = clampedArgs(); + const detector = createSidebarCollisionDetection(() => true, { + emptyPins: true, + activationY: args.pointerCoordinates.y + 6, + }); + expect(args.droppableRects.get(sidebarMarkerId("pinned-header"))?.height).toBe(0); + expect(closestCenter(args)[0]?.id).toBe("source"); + expect(detector(args)[0]?.id).toBe(sidebarMarkerId("pinned-header")); + }); + + it.each([ + { reason: "below the boundary cue", x: 130, y: 109, activationY: 140, emptyPins: true }, + { reason: "left of the list", x: -1, y: 108, activationY: 140, emptyPins: true }, + { reason: "right of the list", x: 261, y: 108, activationY: 140, emptyPins: true }, + { reason: "less than 6px upward", x: 130, y: 108, activationY: 113, emptyPins: true }, + { reason: "without an activation point", x: 130, y: 108, activationY: null, emptyPins: true }, + { reason: "with populated Pins", x: 130, y: 108, activationY: 140, emptyPins: false }, + ])("keeps ordinary collision behavior $reason", ({ x, y, activationY, emptyPins }) => { + const detector = createSidebarCollisionDetection(() => true, { emptyPins, activationY }); + const args = { ...clampedArgs(), pointerCoordinates: { x, y } }; + expect(detector(args)[0]?.id).toBe("source"); + }); + + it("keeps ordinary collision behavior without pointer coordinates", () => { + const detector = createSidebarCollisionDetection(() => true, { + emptyPins: true, + activationY: 140, + }); + expect(detector({ ...clampedArgs(), pointerCoordinates: null })[0]?.id).toBe("source"); + }); + + it("validates the empty Pins override and caches an unsupported result", () => { + const isValid = vi.fn(() => false); + const detector = createSidebarCollisionDetection(isValid, { + emptyPins: true, + activationY: 140, + }); + const args = clampedArgs(); + expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); + expect(detector(args).map((collision) => collision.id)).toEqual(["source"]); + expect(isValid.mock.calls).toEqual([[sidebarMarkerId("pinned-header")]]); + }); + + it("returns no collision if an unsupported target has no source fallback", () => { + const args = collisionArgs(); + const detector = createSidebarCollisionDetection(() => false); + expect( + detector({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container) => container.id !== "source", + ), + }), + ).toEqual([]); + }); + + it("validates each hovered target once and always allows returning to the source", () => { + const args = collisionArgs(); + const isValid = vi.fn((id: string) => id !== "blocked"); + const detector = createSidebarCollisionDetection(isValid); + expect(detector(args)[0]?.id).toBe("source"); + expect( + detector({ + ...args, + collisionRect: { + ...args.collisionRect, + top: args.collisionRect.top + 3, + bottom: args.collisionRect.bottom + 3, + }, + })[0]?.id, + ).toBe("source"); + expect(detector({ ...args, collisionRect: args.droppableRects.get("source")! })[0]?.id).toBe( + "source", + ); + expect( + detector({ + ...args, + collisionRect: args.droppableRects.get(sidebarMarkerId("settled-placeholder"))!, + })[0]?.id, + ).toBe(sidebarMarkerId("settled-placeholder")); + expect(isValid.mock.calls).toEqual([["blocked"], [sidebarMarkerId("settled-placeholder")]]); + }); +}); + +describe("sidebar drag projection", () => { + const pinned = [ + pinnedHeader, + thread("p1", "pinned"), + thread("p2", "pinned"), + divider, + thread("a1", "active"), + settledHeader, + thread("s1", "settled"), + ]; + + it.each([ + ["p1", "p2"], + ["p2", "p1"], + ])("preserves existing pinned transforms from %s to %s", (active, over) => { + const strategy = createSidebarSortingStrategy({ + items: pinned, + settledOrder: [], + settledExpanded: true, + }); + const args = layout(pinned, active, over); + for (let index = 0; index < pinned.length; index += 1) { + expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); + } + }); + + it("keeps the pinned header above the gap when a lower pin moves to the top", () => { + const result = preview( + { items: pinned, settledOrder: [], settledExpanded: true }, + "p2", + sidebarMarkerId("pinned-header"), + ); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get("p1")).toEqual({ ...stationary, y: 83 }); + expect(result.get(sidebarMarkerId("pinned-divider"))).toEqual(stationary); + expect(result.get("a1")).toEqual(stationary); + }); + + it.each([ + ["a1", "a2"], + ["a2", "a1"], + ])("uses pinned dragging behavior for Active from %s to %s", (active, over) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: true, + }); + const args = layout(items, active, over); + for (let index = 0; index < items.length; index += 1) { + expect(strategy({ ...args, index })).toEqual(verticalListSortingStrategy({ ...args, index })); + } + }); + + it("leaves canonically sorted settled peers in place", () => { + const items = [ + pinnedHeader, + divider, + marker("active-placeholder"), + settledHeader, + thread("first", "settled"), + thread("second", "settled"), + ]; + const result = preview( + { items, settledOrder: ["first", "second"], settledExpanded: true }, + "second", + "first", + ); + expect([...result.values()]).toEqual(items.map(() => stationary)); + }); + + it.each([ + [sidebarMarkerId("pinned-divider"), 0, 0], + ["a1", -83, 0], + ["a2", -83, -83], + ] as const)( + "opens the active pointer slot over %s without adding an empty pinned row", + (over, a1Offset, a2Offset) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview({ items, settledOrder: [], settledExpanded: true }, "p", over); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(-83); + expect(result.get("a1")?.y).toBe(a1Offset); + expect(result.get("a2")?.y).toBe(a2Offset); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); + }, + ); + + it("keeps the pinned header above the first arriving pin", () => { + const items = [ + pinnedHeader, + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + "a2", + sidebarMarkerId("pinned-header"), + ); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(83); + expect(result.get("a1")?.y).toBe(83); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(0); + }); + + it.each([ + ["p", -83, -37], + ["s", 0, 46], + ] as const)( + "replaces the empty Active target when %s enters", + (active, dividerOffset, settledOffset) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + active, + sidebarMarkerId("active-placeholder"), + ); + expect(result.get(sidebarMarkerId("active-placeholder"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(dividerOffset); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(settledOffset); + }, + ); + + it("uses the canonical settled rank and the destination's slim height", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + thread("s1", "settled"), + thread("s2", "settled"), + ]; + const result = preview( + { items, settledOrder: ["s1", "a", "s2"], settledExpanded: true }, + "a", + "s2", + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46); + expect(result.get("s1")?.y).toBe(-46); + expect(result.get("s2")?.y).toBe(-9); + }); + + it.each([ + ["a1", 83], + ["a2", 0], + ] as const)( + "reserves a full card at the pointer slot over %s when a slim row enters Active", + (over, firstOffset) => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview({ items, settledOrder: [], settledExpanded: true }, "s", over); + expect(result.get("a1")?.y).toBe(firstOffset); + expect(result.get("a2")?.y).toBe(83); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83); + }, + ); + + it("removes the snoozed header when its last row leaves", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("snoozed-header"), + thread("z", "snoozed"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview({ items, settledOrder: [], settledExpanded: true }, "z", "a"); + expect(result.get(sidebarMarkerId("snoozed-header"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(13); + expect(result.get("s")?.y).toBe(13); + }); + + it("keeps a collapsed settled target without inserting a hidden row", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a1", "active"), + thread("a2", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: false }, + "a2", + sidebarMarkerId("settled-placeholder"), + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-83); + expect(result.get(sidebarMarkerId("settled-placeholder"))).toEqual({ ...stationary, y: -83 }); + }); + + it("preserves a collapsed snoozed header while another section changes", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("snoozed-header"), + settledHeader, + marker("settled-placeholder"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: false }, + "a", + sidebarMarkerId("settled-placeholder"), + ); + expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: -46 }); + }); + + it("derives missing card geometry from the measured root scale", () => { + const items = [ + pinnedHeader, + divider, + marker("active-placeholder"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { items, settledOrder: [], settledExpanded: true }, + "s", + sidebarMarkerId("pinned-header"), + 0.75, + ); + expect(result.get(sidebarMarkerId("pinned-header"))).toEqual(stationary); + expect(result.get(sidebarMarkerId("pinned-divider"))?.y).toBe(62.5); + expect(result.get(sidebarMarkerId("active-placeholder"))?.y).toBe(62.5); + }); + + it("updates the projection when the target or measured geometry changes", () => { + const strategy = createSidebarSortingStrategy({ + items: pinned, + settledOrder: [], + settledExpanded: true, + }); + const args = layout(pinned, "p1", "p1"); + expect(strategy({ ...args, index: 2 })?.y).toBe(0); + expect(strategy({ ...args, index: 2, overIndex: 4 })?.y).toBe(-83); + const smaller = layout(pinned, "p1", "a1", 0.75); + expect(strategy({ ...smaller, index: 2 })?.y).toBe(-62.5); + }); + + it("uses measured placeholder sizing when card height differs from its default", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + const strategy = createSidebarSortingStrategy({ + items, + settledOrder: [], + settledExpanded: false, + }); + const args = layout(items, "a", sidebarMarkerId("settled-placeholder"), 1, 78); + expect(strategy({ ...args, index: 4 })?.y).toBe(-42); + }); + + it("keeps the route row visible after a settled drop pushes it beyond the page", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + thread("s", "settled"), + ]; + const input = { + items, + settledOrder: ["a", "s", "hidden"], + settledExpanded: true, + settledVisibleCount: 1, + }; + const withRoute = preview({ ...input, routeThreadKey: "s" }, "a", "s"); + const withoutRoute = preview(input, "a", "s"); + expect(withRoute.get("s")).toEqual({ ...stationary, y: -9 }); + expect(withoutRoute.get("s")?.scaleY).toBe(0); + }); + + it("reserves the next page row when a visible settled thread leaves", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + thread("s1", "settled"), + thread("route", "settled"), + ]; + const result = preview( + { + items, + settledOrder: ["s1", "hidden", "route"], + settledExpanded: true, + settledVisibleCount: 1, + routeThreadKey: "route", + }, + "s1", + "a", + ); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(83); + expect(result.get("route")?.y).toBe(83); + }); + + it("keeps the dropped route thread visible in a collapsed settled shelf", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + settledHeader, + marker("settled-placeholder"), + ]; + const result = preview( + { + items, + settledOrder: ["a", "hidden"], + settledExpanded: false, + settledVisibleCount: 1, + routeThreadKey: "a", + }, + "a", + sidebarMarkerId("settled-placeholder"), + ); + expect(result.get(sidebarMarkerId("settled-placeholder"))?.scaleY).toBe(0); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(-46); + }); + + it("preserves hidden snoozed membership when the only rendered route row leaves", () => { + const items = [ + pinnedHeader, + thread("p", "pinned"), + divider, + thread("a", "active"), + marker("snoozed-header"), + thread("z", "snoozed"), + settledHeader, + thread("s", "settled"), + ]; + const result = preview( + { + items, + settledOrder: ["s"], + settledExpanded: true, + snoozedThreadCount: 2, + }, + "z", + "a", + ); + expect(result.get(sidebarMarkerId("snoozed-header"))).toEqual({ ...stationary, y: 83 }); + expect(result.get(sidebarMarkerId("settled-header"))?.y).toBe(46); + }); +}); diff --git a/apps/web/src/components/Sidebar.drag.ts b/apps/web/src/components/Sidebar.drag.ts new file mode 100644 index 000000000000..9c27a31c2224 --- /dev/null +++ b/apps/web/src/components/Sidebar.drag.ts @@ -0,0 +1,175 @@ +import { closestCenter, type CollisionDetection } from "@dnd-kit/core"; +import { verticalListSortingStrategy, type SortingStrategy } from "@dnd-kit/sortable"; +import { + resolveSidebarDropTarget, + sidebarListItemId, + sidebarMarkerId, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, +} from "./Sidebar.logic"; + +const stationary = { x: 0, y: 0, scaleX: 1, scaleY: 1 }; +const hidden = { ...stationary, scaleY: 0 }; +type ThreadItem = Extract; +type Layout = Parameters[0]; + +/** Reject the nearest unsupported target without selecting another section. + * Recreate this detector when drop eligibility changes. */ +export function createSidebarCollisionDetection( + isValidTarget: (id: string) => boolean, + options: { emptyPins?: boolean; activationY?: number | null } = {}, +): CollisionDetection { + const validity = new Map(); + const pinnedHeaderId = sidebarMarkerId("pinned-header"); + return (args) => { + let collisions = closestCenter(args); + const pinnedRect = options.emptyPins ? args.droppableRects.get(pinnedHeaderId) : undefined; + const pointer = args.pointerCoordinates; + // The card itself is clamped by the scroll container. An upward pointer + // gesture can still reach the empty pinned boundary without reserving a row. + if ( + pinnedRect && + pointer && + options.activationY != null && + pointer.y <= options.activationY - 6 && + pointer.y <= pinnedRect.top + 8 && + pointer.x >= pinnedRect.left && + pointer.x <= pinnedRect.right + ) { + const pinned = collisions.find((collision) => collision.id === pinnedHeaderId); + if (pinned) { + collisions = [pinned, ...collisions.filter((collision) => collision !== pinned)]; + } + } + const nearest = collisions[0]; + if (!nearest || nearest.id === args.active.id) return collisions; + const id = String(nearest.id); + const valid = validity.get(id) ?? isValidTarget(id); + validity.set(id, valid); + return valid ? collisions : collisions.filter((collision) => collision.id === args.active.id); + }; +} + +/** Preview the committed section layout without moving or mounting DOM nodes. + * A zero scaleY marks rows/markers to hide while retaining their measured nodes. */ +export function createSidebarSortingStrategy(input: { + items: readonly SidebarListItem[]; + settledOrder: readonly string[]; + settledExpanded: boolean; + settledVisibleCount?: number; + routeThreadKey?: string | null; + snoozedThreadCount?: number; + cardHeight?: number; + slimHeight?: number; +}): SortingStrategy { + const { items } = input; + const indices = new Map(items.map((item, index) => [sidebarListItemId(item), index])); + let previous: Pick | undefined; + let transforms: ReturnType[] | null = []; + + function project({ rects, activeIndex, overIndex }: Layout) { + const active = items[activeIndex]; + const over = items[overIndex]; + if (active?.kind !== "thread" || !over || !rects[0]) return []; + const target = resolveSidebarDropTarget(items, active.key, sidebarListItemId(over)); + if (!target) return []; + if (target.section === active.section && over.kind === "thread") + return target.section === "settled" ? [] : null; + const groups: Record = { + pinned: [], + active: [], + snoozed: [], + settled: [], + }; + let cardHeight = input.cardHeight; + let slimHeight = input.slimHeight; + for (const [index, item] of items.entries()) { + if (item.kind === "marker") { + if (item.marker.endsWith("placeholder")) slimHeight ??= rects[index]?.height; + continue; + } + if (item.section === "pinned" || item.section === "active") + cardHeight ??= rects[index]?.height; + else slimHeight ??= rects[index]?.height; + if (item.key !== active.key) groups[item.section].push(item); + } + // Cards are 4.875rem + 0.25rem padding; slim rows/placeholders are h-9. + const scale = slimHeight !== undefined ? slimHeight / 36 : (cardHeight ?? 82) / 82; + cardHeight ??= 82 * scale; + slimHeight ??= 36 * scale; + const group = groups[target.section]; + const order = + target.section === "pinned" + ? target.pinnedOrder + : target.section === "settled" + ? input.settledOrder + : target.activeOrder; + const ranks = new Map(order.map((key, index) => [key, index])); + const rank = ranks.get(active.key) ?? Number.POSITIVE_INFINITY; + const index = group.findIndex( + (item) => (ranks.get(item.key) ?? Number.POSITIVE_INFINITY) > rank, + ); + group.splice(index < 0 ? group.length : index, 0, { ...active, section: target.section }); + const settledOrder = ( + input.settledOrder.length > 0 ? input.settledOrder : groups.settled.map((item) => item.key) + ).filter((key) => key !== active.key || target.section === "settled"); + const visible = input.settledExpanded + ? settledOrder.slice(0, input.settledVisibleCount ?? settledOrder.length) + : []; + const routeKey = input.routeThreadKey; + if (routeKey && settledOrder.includes(routeKey) && !visible.includes(routeKey)) { + visible.push(routeKey); + } + groups.settled = visible.map((key) => ({ kind: "thread", key, section: "settled" })); + const projected: SidebarListItem[] = []; + const marker = (name: SidebarListMarker) => projected.push({ kind: "marker", marker: name }); + const section = (name: "active" | "settled") => { + if (groups[name].length > 0) projected.push(...groups[name]); + else marker(`${name}-placeholder`); + }; + marker("pinned-header"); + projected.push(...groups.pinned); + marker("pinned-divider"); + section("active"); + if ( + groups.snoozed.length > 0 || + ((active.section !== "snoozed" || (input.snoozedThreadCount ?? 0) > 1) && + items.some((item) => item.kind === "marker" && item.marker === "snoozed-header")) + ) { + marker("snoozed-header"); + projected.push(...groups.snoozed); + } + marker("settled-header"); + section("settled"); + const result = items.map(() => hidden); + let top = rects[0].top; + for (const item of projected) { + const index = indices.get(sidebarListItemId(item)); + const rect = index === undefined ? undefined : rects[index]; + if (index !== undefined && rect) result[index] = { ...stationary, y: top - rect.top }; + const fallback = + item.kind === "thread" && (item.section === "pinned" || item.section === "active") + ? cardHeight + : slimHeight; + const moved = item.kind === "thread" && item.key === active.key; + top += (moved ? fallback : (rect?.height ?? fallback)) + 1; + } + result[activeIndex] = stationary; + return result; + } + + return (args) => { + if ( + previous?.rects !== args.rects || + previous.activeIndex !== args.activeIndex || + previous.overIndex !== args.overIndex + ) { + previous = args; + transforms = project(args); + } + return transforms === null + ? verticalListSortingStrategy(args) + : (transforms[args.index] ?? stationary); + }; +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index fee7ef181a59..edf51aa2d8f5 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -3,7 +3,8 @@ import { defaultAnimateLayoutChanges, type AnimateLayoutChanges } from "@dnd-kit import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { - animatePinnedLayoutChanges, + animateSidebarLayoutChanges, + applySidebarThreadDrop, archiveSelectedThreadEntries, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, @@ -32,14 +33,21 @@ import { shouldRecedeSidebarThread, sortLogicalProjectsForSidebar, sortSettledThreadsForSidebar, + resolveSidebarDropTarget, pinOrderKeyBetween, planPinnedReorder, + planSidebarThreadDrop, + sidebarMarkerId, + sidebarListItemId, sortPinnedThreadsForSidebar, sortThreadsForSidebar, sortProjectsForSidebar, sortScopedProjectsForSidebar, shouldCreateNewThreadInCurrentProject, THREAD_JUMP_HINT_SHOW_DELAY_MS, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, } from "./Sidebar.logic"; import { EnvironmentId, @@ -53,12 +61,13 @@ import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, type Project, + type SidebarThreadSummary, type Thread, } from "../types"; const localEnvironmentId = EnvironmentId.make("environment-local"); -describe("animatePinnedLayoutChanges", () => { +describe("animateSidebarLayoutChanges", () => { const baseArgs: Parameters[0] = { active: null, containerId: "pinned-threads", @@ -76,11 +85,11 @@ describe("animatePinnedLayoutChanges", () => { it("does not replay layout movement after the pointer is released", () => { expect(defaultAnimateLayoutChanges(baseArgs)).toBe(true); - expect(animatePinnedLayoutChanges(baseArgs)).toBe(false); + expect(animateSidebarLayoutChanges(baseArgs)).toBe(false); }); it("keeps layout movement while the user is sorting", () => { - expect(animatePinnedLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); + expect(animateSidebarLayoutChanges({ ...baseArgs, isSorting: true })).toBe(true); }); }); @@ -1025,6 +1034,715 @@ describe("planPinnedReorder", () => { }); }); +describe("resolveSidebarDropTarget", () => { + const thread = (key: string, section: SidebarSection): SidebarListItem => ({ + kind: "thread", + key, + section, + }); + const marker = (marker: SidebarListMarker): SidebarListItem => ({ kind: "marker", marker }); + // Pinned p1 p2 | Active a1 a2 | Snoozed z1 | Settled s1 + const items: readonly SidebarListItem[] = [ + marker("pinned-header"), + thread("p1", "pinned"), + thread("p2", "pinned"), + marker("pinned-divider"), + thread("a1", "active"), + thread("a2", "active"), + marker("snoozed-header"), + thread("z1", "snoozed"), + marker("settled-header"), + thread("s1", "settled"), + ]; + const resolve = (activeKey: string, overId: string) => + resolveSidebarDropTarget(items, activeKey, overId); + + it("keeps marker-like scoped thread keys draggable", () => { + const key = "marker:pinned-header"; + const list: SidebarListItem[] = [ + marker("pinned-header"), + thread(key, "pinned"), + thread("env:other", "pinned"), + marker("pinned-divider"), + ]; + expect(new Set(list.map(sidebarListItemId)).size).toBe(list.length); + expect(resolveSidebarDropTarget(list, key, "env:other")).toEqual({ + section: "pinned", + pinnedOrder: ["env:other", key], + activeOrder: [], + }); + }); + + it("reads the section off the markers above the gap", () => { + expect(resolve("p1", "a2")).toEqual({ + section: "active", + pinnedOrder: ["p2"], + activeOrder: ["a1", "a2", "p1"], + }); + expect(resolve("a1", "s1")).toEqual({ + section: "settled", + pinnedOrder: ["p1", "p2"], + activeOrder: ["a2"], + }); + expect(resolve("s1", "a1")).toEqual({ + section: "active", + pinnedOrder: ["p1", "p2"], + activeOrder: ["s1", "a1", "a2"], + }); + }); + + it("uses arrayMove placement, so a marker hovered from below lands above it", () => { + // Dragging a1 up onto the divider: the divider shifts down, a1 becomes + // the last pinned row. + expect(resolve("a1", sidebarMarkerId("pinned-divider"))).toEqual({ + section: "pinned", + pinnedOrder: ["p1", "p2", "a1"], + activeOrder: ["a2"], + }); + // Dragging p2 down onto the divider: the divider shifts up, p2 is the + // first inbox row — an unpin. + expect(resolve("p2", sidebarMarkerId("pinned-divider"))).toEqual({ + section: "active", + pinnedOrder: ["p1"], + activeOrder: ["p2", "a1", "a2"], + }); + // Same on the Settled header: from above it settles; from below the + // gap lands in whatever is above the header — here the snoozed shelf, + // which is never a target. + expect(resolve("a2", sidebarMarkerId("settled-header"))?.section).toBe("settled"); + expect(resolve("s1", sidebarMarkerId("settled-header"))).toBeNull(); + }); + + it("reorders inside the pinned block with the dragged row at the over slot", () => { + expect(resolve("p1", "p2")).toEqual({ + section: "pinned", + pinnedOrder: ["p2", "p1"], + activeOrder: ["a1", "a2"], + }); + expect(resolve("a2", "p1")).toEqual({ + section: "pinned", + pinnedOrder: ["a2", "p1", "p2"], + activeOrder: ["a1"], + }); + }); + + it("lands first in Pinned when hovering its permanent header", () => { + expect(resolve("a2", sidebarMarkerId("pinned-header"))).toEqual({ + section: "pinned", + pinnedOrder: ["a2", "p1", "p2"], + activeOrder: ["a1"], + }); + }); + + it("reorders active rows in either direction without changing sections", () => { + for (const [from, to] of [ + ["a1", "a2"], + ["a2", "a1"], + ] as const) { + expect(resolve(from, to)).toEqual({ + section: "active", + pinnedOrder: ["p1", "p2"], + activeOrder: ["a2", "a1"], + }); + } + }); + + it("never lands in the snoozed shelf", () => { + expect(resolve("a1", "z1")).toBeNull(); + expect(resolve("a1", sidebarMarkerId("snoozed-header"))).toBeNull(); + }); + + it("lands on a placeholder when the section is otherwise empty", () => { + const withPlaceholder: readonly SidebarListItem[] = [ + marker("pinned-header"), + marker("pinned-divider"), + thread("a1", "active"), + marker("settled-header"), + marker("settled-placeholder"), + ]; + expect( + resolveSidebarDropTarget(withPlaceholder, "a1", sidebarMarkerId("settled-placeholder")), + ).toEqual({ section: "settled", pinnedOrder: [], activeOrder: [] }); + }); + + it("lands in empty Pinned using its header without an extra placeholder", () => { + const emptyPinned: readonly SidebarListItem[] = [ + marker("pinned-header"), + marker("pinned-divider"), + thread("a1", "active"), + ]; + expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-header"))).toEqual({ + section: "pinned", + pinnedOrder: ["a1"], + activeOrder: [], + }); + expect(resolveSidebarDropTarget(emptyPinned, "a1", sidebarMarkerId("pinned-divider"))).toEqual({ + section: "pinned", + pinnedOrder: ["a1"], + activeOrder: [], + }); + }); + + it("rejects ids that are not in the list", () => { + expect(resolve("a1", "nope")).toBeNull(); + expect(resolve("nope", "a1")).toBeNull(); + expect(resolve(sidebarMarkerId("pinned-divider"), "a1")).toBeNull(); + }); +}); + +describe("planSidebarThreadDrop", () => { + const pinnedKeysById = new Map([ + ["p1", "f"], + ["p2", "m"], + ["p3", "t"], + ]); + const activeKeysById = new Map([ + ["a1", "f"], + ["a2", "m"], + ["a3", "t"], + ]); + const plan = ( + overrides: Partial[0], "target">> & { + activeKey: string; + activeSection: "pinned" | "active" | "snoozed" | "settled"; + target: Omit[0]["target"], "activeOrder"> & { + activeOrder?: readonly string[]; + }; + }, + ) => + planSidebarThreadDrop({ + pinnedOrder: ["p1", "p2", "p3"], + pinnedKeysById, + activeOrder: ["a1", "a2", "a3"], + activeKeysById, + ...overrides, + target: { activeOrder: [], ...overrides.target }, + }); + + it("allows old-server pinned reordering while rejecting settlement", () => { + expect( + plan({ + activeKey: "p1", + activeSection: "pinned", + supportsSettlement: false, + target: { section: "pinned", pinnedOrder: ["p2", "p1", "p3"] }, + }).kind, + ).toBe("reorder-pinned"); + expect( + plan({ + activeKey: "p1", + activeSection: "pinned", + supportsSettlement: false, + target: { section: "settled", pinnedOrder: ["p2", "p3"] }, + }), + ).toEqual({ kind: "none" }); + }); + + it.each(["pinned", "active"] as const)("reserves hidden %s slots during a drop", (section) => { + const order = section === "pinned" ? ["p2", "p1", "p3"] : ["a2", "a1", "a3"]; + const keys = new Map(section === "pinned" ? pinnedKeysById : activeKeysById); + const moved = section === "pinned" ? "p1" : "a1"; + const reserved = pinOrderKeyBetween(keys.get(order[0]!)!, keys.get(order[2]!)!)!; + keys.set("snoozed", reserved); + const result = plan({ + activeKey: moved, + activeSection: section, + pinnedKeysById: section === "pinned" ? keys : pinnedKeysById, + activeKeysById: section === "active" ? keys : activeKeysById, + target: { + section, + pinnedOrder: section === "pinned" ? order : [], + activeOrder: section === "active" ? order : [], + }, + }); + if (result.kind !== "reorder-pinned" && result.kind !== "move-active") + throw new Error("Expected reorder"); + expect(result.assignments).toHaveLength(1); + expect(result.assignments[0]!.orderKey).not.toBe(reserved); + }); + + it.each([ + { key: "p2", section: "pinned" as const, unpin: true, unsettle: false, unsnooze: false }, + { key: "s1", section: "settled" as const, unpin: false, unsettle: true, unsnooze: false }, + { key: "z1", section: "snoozed" as const, unpin: false, unsettle: false, unsnooze: true }, + ])("moves a $section thread to the chosen Active slot", (source) => { + const order = ["a1", source.key, "a2", "a3"]; + const result = plan({ + activeKey: source.key, + activeSection: source.section, + target: { section: "active", pinnedOrder: [], activeOrder: order }, + }); + expect(result).toEqual({ + kind: "move-active", + order, + assignments: [{ id: source.key, orderKey: expect.any(String) }], + unpin: source.unpin, + unsettle: source.unsettle, + unsnooze: source.unsnooze, + }); + if (result.kind !== "move-active") return; + const key = result.assignments[0]!.orderKey; + expect(key > "f" && key < "m").toBe(true); + }); + + it.each([ + { state: "pinned", activePinned: true, activeSettled: false }, + { state: "settled", activePinned: false, activeSettled: true }, + { state: "pinned and settled", activePinned: true, activeSettled: true }, + ])("clears a snoozed thread's $state state before waking it into Active", (hiddenState) => { + expect( + plan({ + activeKey: "z1", + activeSection: "snoozed", + activePinned: hiddenState.activePinned, + activeSettled: hiddenState.activeSettled, + target: { + section: "active", + pinnedOrder: ["p1", "p2", "p3"], + activeOrder: ["a1", "z1", "a2", "a3"], + }, + }), + ).toEqual({ + kind: "move-active", + order: ["a1", "z1", "a2", "a3"], + assignments: [{ id: "z1", orderKey: expect.any(String) }], + unpin: hiddenState.activePinned, + unsettle: hiddenState.activeSettled, + unsnooze: true, + }); + }); + + it("saves the first Active reorder, then moves only one key on subsequent drops", () => { + const rows = ["a1", "a2", "a3"].map((id, index) => ({ + id, + createdAt: new Date(Date.UTC(2026, 8, 4, 12 - index)).toISOString(), + activeOrderKey: null as string | null, + })); + const firstOrder = ["a2", "a3", "a1"]; + const first = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "active", pinnedOrder: [], activeOrder: firstOrder }, + activeKeysById: new Map(rows.map((row) => [row.id, row.activeOrderKey])), + }); + expect(first.kind).toBe("move-active"); + if (first.kind !== "move-active") return; + expect(first.unpin || first.unsettle || first.unsnooze).toBe(false); + const savedKeys = new Map(first.assignments.map(({ id, orderKey }) => [id, orderKey])); + const savedRows = rows.map((row) => ({ + ...row, + activeOrderKey: savedKeys.get(row.id) ?? null, + })); + expect(sortThreadsForSidebar(savedRows).map((row) => row.id)).toEqual(firstOrder); + + const secondOrder = ["a2", "a1", "a3"]; + const second = plan({ + activeKey: "a1", + activeSection: "active", + activeOrder: firstOrder, + activeKeysById: savedKeys, + target: { section: "active", pinnedOrder: [], activeOrder: secondOrder }, + }); + expect(second.kind).toBe("move-active"); + if (second.kind !== "move-active") return; + expect(second.assignments).toEqual([{ id: "a1", orderKey: expect.any(String) }]); + const finalRows = savedRows.map((row) => + row.id === "a1" ? { ...row, activeOrderKey: second.assignments[0]!.orderKey } : row, + ); + expect(sortThreadsForSidebar(finalRows).map((row) => row.id)).toEqual(secondOrder); + }); + + it("does not write when an Active thread is dropped in its existing slot", () => { + expect( + plan({ + activeKey: "a2", + activeSection: "active", + target: { section: "active", pinnedOrder: [], activeOrder: ["a1", "a2", "a3"] }, + }), + ).toEqual({ kind: "none" }); + }); + + it("requires Active ordering support only for the threads whose keys must change", () => { + const input = { + activeKey: "a3", + activeSection: "active" as const, + target: { section: "active" as const, pinnedOrder: [], activeOrder: ["a1", "a3", "a2"] }, + activeReorderableKeys: new Set(["a3"]), + }; + expect(plan(input).kind).toBe("move-active"); + expect( + plan({ + ...input, + activeKeysById: new Map([ + ["a1", null], + ["a2", "m"], + ["a3", "t"], + ]), + }), + ).toEqual({ kind: "none" }); + expect(plan({ ...input, activeReorderableKeys: new Set() })).toEqual({ kind: "none" }); + }); + + it("settles anything dropped on Settled except a settled thread", () => { + const target = { section: "settled", pinnedOrder: ["p1", "p2", "p3"] } as const; + expect(plan({ activeKey: "a1", activeSection: "active", target })).toEqual({ kind: "settle" }); + expect(plan({ activeKey: "p1", activeSection: "pinned", target })).toEqual({ kind: "settle" }); + expect(plan({ activeKey: "z1", activeSection: "snoozed", target })).toEqual({ kind: "settle" }); + expect(plan({ activeKey: "s1", activeSection: "settled", target })).toEqual({ kind: "none" }); + }); + + it("pins a foreign thread with a key between its new neighbors", () => { + const result = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] }, + }); + expect(result.kind).toBe("pin"); + if (result.kind !== "pin") return; + expect(result.order).toEqual(["p1", "a1", "p2", "p3"]); + expect(result.orderKey).toBeDefined(); + expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true); + expect(result.extraAssignments).toEqual([]); + + const empty = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["a1"] }, + pinnedOrder: [], + pinnedKeysById: new Map(), + }); + expect(empty.kind).toBe("pin"); + if (empty.kind !== "pin") return; + expect(empty.orderKey).toBeDefined(); + }); + + it("reorders an already-pinned snoozed thread after pinning wakes it", () => { + const result = plan({ + activeKey: "z1", + activeSection: "snoozed", + activePinned: true, + target: { section: "pinned", pinnedOrder: ["p1", "z1", "p2", "p3"] }, + pinnedKeysById: new Map([...pinnedKeysById, ["z1", "x"]]), + }); + expect(result.kind).toBe("pin"); + if (result.kind !== "pin") return; + expect(result.extraAssignments).toEqual([{ id: "z1", orderKey: result.orderKey }]); + expect(result.orderKey! > "f" && result.orderKey! < "m").toBe(true); + }); + + it("uses keyed disabled neighbors as anchors without writing to them", () => { + const insertion = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] }, + reorderableKeys: new Set(["a1"]), + }); + expect(insertion.kind).toBe("pin"); + if (insertion.kind !== "pin") return; + expect(insertion.order).toEqual(["p1", "a1", "p2", "p3"]); + expect(insertion.orderKey! > "f" && insertion.orderKey! < "m").toBe(true); + expect(insertion.extraAssignments).toEqual([]); + + const reorder = plan({ + activeKey: "p3", + activeSection: "pinned", + target: { section: "pinned", pinnedOrder: ["p1", "p3", "p2"] }, + reorderableKeys: new Set(["p3"]), + }); + expect(reorder.kind).toBe("reorder-pinned"); + if (reorder.kind !== "reorder-pinned") return; + expect(reorder.assignments).toEqual([{ id: "p3", orderKey: expect.any(String) }]); + expect(reorder.assignments[0]!.orderKey > "f").toBe(true); + expect(reorder.assignments[0]!.orderKey < "m").toBe(true); + }); + + it.each([ + { + activeKey: "a1", + activeSection: "active" as const, + order: ["p1", "p3", "a1", "p2"], + }, + { activeKey: "p1", activeSection: "pinned" as const, order: ["p3", "p1", "p2"] }, + ])("rejects $activeSection drops that require rewriting a disabled neighbor", (source) => { + expect( + plan({ + activeKey: source.activeKey, + activeSection: source.activeSection, + target: { section: "pinned", pinnedOrder: source.order }, + pinnedOrder: ["p1", "p3", "p2"], + pinnedKeysById: new Map([ + ["p1", "f"], + ["p2", null], + ["p3", "t"], + ]), + reorderableKeys: new Set(["p1", "p3", source.activeKey]), + }), + ).toEqual({ kind: "none" }); + }); + + it("rewrites the section when a foreign thread lands next to a keyless pin", () => { + const result = plan({ + activeKey: "a1", + activeSection: "active", + target: { section: "pinned", pinnedOrder: ["p1", "a1", "p2", "p3"] }, + pinnedKeysById: new Map([ + ["p1", null], + ["p2", "m"], + ["p3", "t"], + ]), + }); + expect(result.kind).toBe("pin"); + if (result.kind !== "pin") return; + expect(result.orderKey).toBeDefined(); + expect(result.extraAssignments.map((entry) => entry.id)).toEqual(["p1", "p2", "p3"]); + const byId = new Map([ + ["a1", result.orderKey!], + ...result.extraAssignments.map((e) => [e.id, e.orderKey] as const), + ]); + const ordered = result.order.map((id) => byId.get(id)!); + expect([...ordered].sort()).toEqual(ordered); + }); + + it("reorders within the pinned block, and is a no-op when the order is unchanged", () => { + const down = plan({ + activeKey: "p1", + activeSection: "pinned", + target: { section: "pinned", pinnedOrder: ["p2", "p3", "p1"] }, + }); + expect(down.kind).toBe("reorder-pinned"); + if (down.kind !== "reorder-pinned") return; + expect(down.assignments).toEqual([{ id: "p1", orderKey: expect.any(String) }]); + expect(down.assignments[0]!.orderKey > "t").toBe(true); + + expect( + plan({ + activeKey: "p1", + activeSection: "pinned", + target: { section: "pinned", pinnedOrder: ["p1", "p2", "p3"] }, + }), + ).toEqual({ kind: "none" }); + }); +}); + +describe("applySidebarThreadDrop", () => { + const createdAt = "2026-03-09T08:00:00.000Z"; + const earlier = "2026-03-09T09:00:00.000Z"; + const now = "2026-03-09T12:00:00.000Z"; + const serverNow = "2026-03-09T12:00:01.000Z"; + const wakeAt = "2026-03-10T08:00:00.000Z"; + const thread = (overrides: Partial = {}) => ({ + id: ThreadId.make("dragged"), + title: "Keep this title", + createdAt, + updatedAt: earlier, + latestUserMessageAt: null, + latestTurn: null, + pinnedAt: null, + pinOrderKey: null, + activeOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + settledAt: null, + settledOverride: null, + unsettledAt: null, + ...overrides, + }); + const newer = thread({ id: ThreadId.make("newer"), createdAt: "2026-03-09T11:00:00.000Z" }); + + it("previews an un-settle at the same active position as the eventual server row", () => { + const source = thread({ settledOverride: "settled", settledAt: earlier }); + const preview = applySidebarThreadDrop(source, "active", now); + const final = { + ...source, + settledOverride: "active" as const, + settledAt: null, + unsettledAt: serverNow, + }; + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([ + "dragged", + "newer", + ]); + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual( + sortThreadsForSidebar([newer, final]).map((row) => row.id), + ); + }); + + it.each([ + { state: "pin", pinnedAt: earlier, pinOrderKey: "m", snoozedAt: null, snoozedUntil: null }, + { + state: "snooze", + pinnedAt: null, + pinOrderKey: null, + snoozedAt: earlier, + snoozedUntil: wakeAt, + }, + { + state: "snoozed pin", + pinnedAt: earlier, + pinOrderKey: "m", + snoozedAt: earlier, + snoozedUntil: wakeAt, + }, + ])("preserves the active sort anchor when clearing a $state", ({ state: _state, ...parked }) => { + const source = thread({ ...parked, settledOverride: "active", unsettledAt: earlier }); + const preview = applySidebarThreadDrop(source, "active", now); + const final = { + ...source, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + updatedAt: serverNow, + }; + expect(preview).toEqual({ ...final, updatedAt: source.updatedAt }); + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual([ + "newer", + "dragged", + ]); + expect(sortThreadsForSidebar([newer, preview]).map((row) => row.id)).toEqual( + sortThreadsForSidebar([newer, final]).map((row) => row.id), + ); + }); + + it("clears underlying pinning and settlement when waking into Active", () => { + const source = thread({ + pinnedAt: earlier, + pinOrderKey: "m", + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "settled", + settledAt: earlier, + }); + expect(applySidebarThreadDrop(source, "active", now)).toEqual({ + ...source, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + settledOverride: "active", + settledAt: null, + unsettledAt: now, + }); + }); + + it("previews a new settlement at the same position as the eventual server row", () => { + const source = thread({ + pinnedAt: earlier, + pinOrderKey: "m", + snoozedAt: earlier, + snoozedUntil: wakeAt, + unsettledAt: earlier, + }); + const preview = applySidebarThreadDrop(source, "settled", now); + const final = { + ...source, + pinnedAt: null, + pinOrderKey: null, + snoozedAt: null, + snoozedUntil: null, + settledOverride: "settled" as const, + settledAt: serverNow, + unsettledAt: null, + }; + const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; + expect(preview).toEqual({ ...final, settledAt: now }); + expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + "dragged", + "newer", + ]); + expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual( + sortSettledThreadsForSidebar([existing, final]).map((row) => row.id), + ); + }); + + it("retains a snoozed thread's earlier settlement and its position when settling again", () => { + const source = thread({ + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "settled", + settledAt: earlier, + }); + const preview = applySidebarThreadDrop(source, "settled", now); + const final = { ...source, snoozedAt: null, snoozedUntil: null }; + const existing = { ...newer, settledOverride: "settled" as const, settledAt: newer.createdAt }; + expect(preview).toEqual(final); + expect(sortSettledThreadsForSidebar([existing, preview]).map((row) => row.id)).toEqual([ + "newer", + "dragged", + ]); + }); + + it("pins a settled thread at its requested slot and projects the re-entry stamp", () => { + const source = thread({ + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "settled", + settledAt: earlier, + }); + const original = { ...source }; + const preview = applySidebarThreadDrop(source, "pinned", now, "m"); + expect(preview).toEqual({ + ...source, + pinnedAt: now, + pinOrderKey: "m", + snoozedAt: null, + snoozedUntil: null, + settledOverride: "active", + settledAt: null, + unsettledAt: now, + }); + expect( + sortPinnedThreadsForSidebar([ + thread({ id: ThreadId.make("after"), pinnedAt: earlier, pinOrderKey: "t" }), + preview, + thread({ id: ThreadId.make("before"), pinnedAt: earlier, pinOrderKey: "f" }), + ]).map((row) => row.id), + ).toEqual(["before", "dragged", "after"]); + expect(source).toEqual(original); + }); + + it("keeps an existing pin's timestamp and key unless the drop supplies a new key", () => { + const source = thread({ + pinnedAt: earlier, + pinOrderKey: "t", + snoozedAt: earlier, + snoozedUntil: wakeAt, + settledOverride: "active", + unsettledAt: earlier, + }); + const unchangedSlot = applySidebarThreadDrop(source, "pinned", now); + expect(unchangedSlot).toEqual({ ...source, snoozedAt: null, snoozedUntil: null }); + expect(applySidebarThreadDrop(source, "pinned", now, "m")).toEqual({ + ...unchangedSlot, + pinOrderKey: "m", + }); + }); + + it("keeps an Active drop at its chosen position after unpinning", () => { + const source = thread({ pinnedAt: earlier, pinOrderKey: "g", activeOrderKey: "z" }); + const preview = applySidebarThreadDrop(source, "active", now, "m"); + expect(preview).toMatchObject({ pinnedAt: null, pinOrderKey: null, activeOrderKey: "m" }); + expect( + sortThreadsForSidebar([ + thread({ id: ThreadId.make("after"), activeOrderKey: "t" }), + preview, + thread({ id: ThreadId.make("before"), activeOrderKey: "f" }), + ]).map((row) => row.id), + ).toEqual(["before", "dragged", "after"]); + }); + + it("clears the manual Active position when settling so reopening returns to the top", () => { + const source = thread({ activeOrderKey: "z" }); + const settled = applySidebarThreadDrop(source, "settled", now); + expect(settled.activeOrderKey).toBeNull(); + const reopened = applySidebarThreadDrop(settled, "active", serverNow); + expect(sortThreadsForSidebar([newer, reopened]).map((row) => row.id)).toEqual([ + "dragged", + "newer", + ]); + }); +}); + describe("sortPinnedThreadsForSidebar", () => { const pinnable = (input: { id: string; createdAt: string; pinOrderKey?: string | null }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index de06237ae41d..0b406c92c442 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -7,8 +7,8 @@ import { import type { ContextMenuItem } from "@t3tools/contracts"; import type { SidebarProjectSortOrder, SidebarThreadSortOrder } from "@t3tools/contracts/settings"; import type { AsyncResult } from "effect/unstable/reactivity"; +import { planPinnedReorder } from "@t3tools/client-runtime/state/thread-sort"; import { - activeThreadAnchorTimestampMs, getThreadSortTimestamp, resolveSettledThreadTimestamp, sortThreads, @@ -81,12 +81,257 @@ export function useRetainedValue(key: string | null, value: T | null): T | nu return key !== null && retained.current?.key === key ? retained.current.value : null; } -// The list already reaches its destination through sortable transforms while -// the pointer is down. dnd-kit's default also animates the committed DOM order -// after release, replaying the same movement across every affected row. -export const animatePinnedLayoutChanges: AnimateLayoutChanges = (args) => +// Sidebar.motion handles ordinary section changes. Sortable transforms own +// dragging; replaying their committed DOM order would animate the drop twice. +export const animateSidebarLayoutChanges: AnimateLayoutChanges = (args) => args.isSorting ? defaultAnimateLayoutChanges(args) : false; +// Rows and section markers share one sortable list. The separators resolve +// the lifecycle action; Sidebar.drag previews the resulting layout. Pinned +// and active threads keep the dragged position; settled threads use time +// order. Snoozed rows can leave the shelf, but dropping into it is not +// supported because snoozing requires a wake time. + +export type SidebarSection = "pinned" | "active" | "snoozed" | "settled"; + +/** Sortable ids: thread rows use their scoped key; structural items use a + colon-free prefix: scoped thread keys always contain a colon. */ +const SIDEBAR_MARKER_PREFIX = "sidebar-marker-"; + +export type SidebarListMarker = + /** The top boundary is also a landing target when there are no pins. */ + | "pinned-header" + /** Stand-in rows so an empty section has somewhere for the gap to open. */ + | "active-placeholder" + | "settled-placeholder" + /** The boundary between pinned and active rows. */ + | "pinned-divider" + | "snoozed-header" + | "settled-header"; + +export function sidebarMarkerId(marker: SidebarListMarker): string { + return `${SIDEBAR_MARKER_PREFIX}${marker}`; +} + +export type SidebarListItem = + | { readonly kind: "thread"; readonly key: string; readonly section: SidebarSection } + | { readonly kind: "marker"; readonly marker: SidebarListMarker }; + +export function sidebarListItemId(item: SidebarListItem): string { + return item.kind === "thread" ? item.key : sidebarMarkerId(item.marker); +} + +/** The section a slot belongs to, read off the markers around it: from + the top down, everything before the pinned divider is pinned, then the + inbox until the snoozed header, the shelf until the settled header, + then settled. */ +function sectionAtSidebarSlot(items: readonly SidebarListItem[], index: number): SidebarSection { + let section: SidebarSection = "pinned"; + for (let i = 0; i < index && i < items.length; i += 1) { + const item = items[i]!; + if (item.kind !== "marker") continue; + if (item.marker === "pinned-divider") section = "active"; + else if (item.marker === "snoozed-header") section = "snoozed"; + else if (item.marker === "settled-header") section = "settled"; + } + return section; +} + +/** Resolve the destination section and manual order from an arrayMove across + * the separators. The snoozed shelf is never a destination. */ +export type SidebarDropTarget = { + readonly section: "pinned" | "active" | "settled"; + readonly pinnedOrder: readonly string[]; + readonly activeOrder: readonly string[]; +}; + +export function resolveSidebarDropTarget( + items: readonly SidebarListItem[], + activeKey: string, + overId: string, +): SidebarDropTarget | null { + const activeIndex = items.findIndex((item) => sidebarListItemId(item) === activeKey); + const overIndex = items.findIndex((item) => sidebarListItemId(item) === overId); + if (activeIndex === -1 || overIndex === -1 || items[activeIndex]?.kind !== "thread") return null; + const moved = items.filter((_, index) => index !== activeIndex); + moved.splice(overIndex, 0, items[activeIndex]!); + const section = sectionAtSidebarSlot(moved, overIndex); + if (section === "snoozed") return null; + const pinnedOrder: string[] = []; + const activeOrder: string[] = []; + let currentSection: SidebarSection = "pinned"; + for (const item of moved) { + if (item.kind === "marker") { + if (item.marker === "pinned-divider") currentSection = "active"; + else if (item.marker === "snoozed-header" || item.marker === "settled-header") break; + } else if (currentSection === "pinned") pinnedOrder.push(item.key); + else activeOrder.push(item.key); + } + return { section, pinnedOrder, activeOrder }; +} + +export type SidebarThreadDropPlan = + | { readonly kind: "none" } + /** Within the pinned block: the existing key writes. */ + | { + readonly kind: "reorder-pinned"; + readonly order: readonly string[]; + readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + } + /** From another section into the pinned block. Fresh pins take `orderKey` + on the pin command. `extraAssignments` land afterward, including the + moved row when it was already pinned beneath a snooze. */ + | { + readonly kind: "pin"; + readonly order: readonly string[]; + readonly orderKey: string | undefined; + readonly extraAssignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + } + | { + readonly kind: "move-active"; + readonly order: readonly string[]; + readonly assignments: ReadonlyArray<{ readonly id: string; readonly orderKey: string }>; + readonly unpin: boolean; + readonly unsettle: boolean; + readonly unsnooze: boolean; + } + | { readonly kind: "settle" }; + +export function planSidebarThreadDrop(input: { + readonly activeKey: string; + readonly activeSection: SidebarSection; + /** Snoozed threads can retain pinning and settlement beneath the shelf. */ + readonly activePinned?: boolean; + readonly activeSettled?: boolean; + readonly supportsSettlement?: boolean; + readonly target: SidebarDropTarget; + /** All pinned keys in displayed order before the drop. */ + readonly pinnedOrder: readonly string[]; + readonly pinnedKeysById: ReadonlyMap; + readonly reorderableKeys?: ReadonlySet; + readonly activeOrder: readonly string[]; + readonly activeKeysById: ReadonlyMap; + readonly activeReorderableKeys?: ReadonlySet; +}): SidebarThreadDropPlan { + const { + activeKey, + activeSection, + activePinned = activeSection === "pinned", + activeSettled = activeSection === "settled", + target, + pinnedOrder, + pinnedKeysById, + reorderableKeys, + activeOrder, + activeKeysById, + activeReorderableKeys, + } = input; + if (input.supportsSettlement === false && (target.section === "settled" || activeSettled)) { + return { kind: "none" }; + } + switch (target.section) { + case "active": { + const order = target.activeOrder; + if ( + activeSection === "active" && + order.length === activeOrder.length && + order.every((key, index) => key === activeOrder[index]) + ) { + return { kind: "none" }; + } + const assignments = planPinnedReorder({ + orderedIds: order, + keysById: activeKeysById, + movedId: activeKey, + }); + if (activeReorderableKeys && assignments.some(({ id }) => !activeReorderableKeys.has(id))) { + return { kind: "none" }; + } + return { + kind: "move-active", + order, + assignments, + unpin: activePinned, + unsettle: activeSettled, + unsnooze: activeSection === "snoozed", + }; + } + case "settled": + return activeSection === "settled" ? { kind: "none" } : { kind: "settle" }; + case "pinned": { + const order = target.pinnedOrder; + // Dropped back where it started: nothing to write. + if ( + activeSection === "pinned" && + order.length === pinnedOrder.length && + order.every((key, index) => key === pinnedOrder[index]) + ) { + return { kind: "none" }; + } + const assignments = planPinnedReorder({ + orderedIds: order, + keysById: pinnedKeysById, + movedId: activeKey, + }); + if (reorderableKeys && assignments.some(({ id }) => !reorderableKeys.has(id))) { + return { kind: "none" }; + } + if (activeSection === "pinned") { + return assignments.length === 0 + ? { kind: "none" } + : { kind: "reorder-pinned", order, assignments }; + } + return { + kind: "pin", + order, + orderKey: assignments.find((assignment) => assignment.id === activeKey)?.orderKey, + extraAssignments: activePinned + ? assignments + : assignments.filter((assignment) => assignment.id !== activeKey), + }; + } + } +} + +/** Project a drop's lifecycle fields before sorting its destination. Reusing + the server's re-entry rules keeps the preview in place when events arrive. */ +export function applySidebarThreadDrop< + T extends Pick< + SidebarThreadSummary, + | "pinnedAt" + | "pinOrderKey" + | "activeOrderKey" + | "snoozedAt" + | "snoozedUntil" + | "settledAt" + | "settledOverride" + | "unsettledAt" + >, +>(thread: T, section: "pinned" | "active" | "settled", now: string, orderKey?: string): T { + const wasSettled = thread.settledOverride === "settled"; + const awake = { ...thread, snoozedAt: null, snoozedUntil: null }; + if (section === "settled") { + return { + ...awake, + pinnedAt: null, + pinOrderKey: null, + activeOrderKey: null, + settledOverride: "settled", + settledAt: wasSettled ? (thread.settledAt ?? now) : now, + unsettledAt: null, + }; + } + const resumed = wasSettled + ? { ...awake, settledOverride: "active" as const, settledAt: null, unsettledAt: now } + : awake; + return { + ...resumed, + pinnedAt: section === "pinned" ? (thread.pinnedAt ?? now) : null, + pinOrderKey: section === "pinned" ? (orderKey ?? thread.pinOrderKey) : null, + ...(section === "active" && orderKey !== undefined ? { activeOrderKey: orderKey } : {}), + }; +} + type SidebarProject = { id: string; title: string; @@ -605,26 +850,7 @@ function firstValidTimestamp( return null; } -// Sidebar sort: static order, newest anchor on top. Activity NEVER reorders -// the list — a row holds its position between lifecycle transitions, so the -// screen only moves when a thread enters or leaves the active list. The -// anchor is creation time until an un-settle re-anchors it (see -// activeThreadAnchorTimestampMs), so an un-settled thread surfaces at the -// top instead of sinking back to its creation-order slot. Status (including -// pending approval) is carried by each card's edge strip, not by position. -export function sortThreadsForSidebar< - T extends { - readonly id: string; - readonly createdAt: string; - readonly unsettledAt?: string | null | undefined; - }, ->(threads: readonly T[]): T[] { - return [...threads].toSorted( - (left, right) => - activeThreadAnchorTimestampMs(right) - activeThreadAnchorTimestampMs(left) || - left.id.localeCompare(right.id), - ); -} +export { sortActiveThreadsByOrderKey as sortThreadsForSidebar } from "@t3tools/client-runtime/state/thread-sort"; // Pinned-reorder key math and the keyed sort live in client-runtime // (state/thread-sort) so web and mobile compute identical pinned orders. diff --git a/apps/web/src/components/Sidebar.motion.test.ts b/apps/web/src/components/Sidebar.motion.test.ts new file mode 100644 index 000000000000..f8570553058a --- /dev/null +++ b/apps/web/src/components/Sidebar.motion.test.ts @@ -0,0 +1,339 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { createSidebarListMotion } from "./Sidebar.motion"; + +class TestAnimation { + progress: number | null = 0; + playState: AnimationPlayState = "running"; + effect = { getComputedTiming: () => ({ progress: this.progress }) }; + cancel = vi.fn(() => { + this.playState = "idle"; + }); + private onFinish: (() => void) | undefined; + addEventListener(_type: string, listener: () => void) { + this.onFinish = listener; + } + finish() { + this.playState = "finished"; + this.onFinish?.(); + } +} + +class TestRow { + offsetTop = 0; + offsetLeft = 4; + offsetWidth = 260; + namespaceURI = "http://www.w3.org/1999/xhtml"; + dragTranslate = 0; + style: Record = {}; + inert = false; + attributes: { name: string; value: string }[] = []; + children: TestRow[] = []; + clones: TestRow[] = []; + remove = vi.fn(); + animations: TestAnimation[] = []; + constructor( + readonly name: string, + public offsetHeight = 82, + ) {} + getBoundingClientRect() { + return { top: this.offsetTop + this.dragTranslate, height: this.offsetHeight }; + } + setAttribute(name: string, value: string) { + this.removeAttribute(name); + this.attributes.push({ name, value }); + } + removeAttribute(name: string) { + this.attributes = this.attributes.filter((attribute) => attribute.name !== name); + } + querySelectorAll(_selector: string): TestRow[] { + return this.children.flatMap((child) => [child, ...child.querySelectorAll("*")]); + } + cloneNode(_deep: boolean): TestRow { + const clone = new TestRow(`${this.name} clone`, this.offsetHeight); + clone.namespaceURI = this.namespaceURI; + clone.style = { ...this.style }; + clone.attributes = this.attributes.map((attribute) => ({ ...attribute })); + clone.children = this.children.map((child) => child.cloneNode(true)); + this.clones.push(clone); + return clone; + } + animate = vi.fn((_frames: Keyframe[], _options: KeyframeAnimationOptions) => { + const animation = new TestAnimation(); + this.animations.push(animation); + return animation; + }); +} + +function fixture(rows: TestRow[]) { + const media = { matches: false }; + const parent = { + children: rows, + ownerDocument: { defaultView: { matchMedia: () => media } }, + append(node: TestRow) { + parent.children.push(node); + node.remove.mockImplementation(() => { + parent.children = parent.children.filter((child) => child !== node); + }); + }, + }; + function layout(next: TestRow[]) { + let top = 8; + for (const row of next) { + row.offsetTop = top; + top += row.offsetHeight + 1; + } + parent.children = [ + ...next, + ...parent.children.filter((row) => row.style.position === "absolute"), + ]; + } + layout(rows); + const motion = createSidebarListMotion(parent as unknown as HTMLUListElement); + return { motion, layout, media, parent }; +} + +function expectMove(row: TestRow, offset: number) { + expect(row.animate).toHaveBeenLastCalledWith( + [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], + { duration: 150, easing: "ease-out" }, + ); +} + +beforeEach(() => vi.stubGlobal("HTMLElement", TestRow)); +afterEach(() => vi.unstubAllGlobals()); + +describe("sidebar list motion", () => { + it("moves a retained Active row into Settled with its displaced peers", () => { + const pinnedHeader = new TestRow("Pinned", 0); + const pinned = new TestRow("pin"); + const divider = new TestRow("Active", 0); + const a = new TestRow("a"); + const b = new TestRow("b"); + const settledHeader = new TestRow("Settled", 32); + const settled = new TestRow("settled", 36); + const rows = [pinnedHeader, pinned, divider, a, b, settledHeader, settled]; + const { motion, layout } = fixture(rows); + motion.update(true); + expect(rows.every((row) => row.animations.length === 0)).toBe(true); + + a.offsetHeight = 36; + layout([pinnedHeader, pinned, divider, b, settledHeader, settled, a]); + motion.update(true); + expectMove(a, -153); + expectMove(b, 83); + expectMove(settledHeader, 83); + expectMove(settled, 83); + expect(pinned.animate).not.toHaveBeenCalled(); + expect(divider.animate).not.toHaveBeenCalled(); + }); + + it("refreshes the drop baseline without replay and animates the next ordinary move", () => { + const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; + const { motion, layout } = fixture([a, b, c]); + motion.update(true); + motion.suspend(); + a.dragTranslate = 300; + b.dragTranslate = -83; + motion.update(false); + motion.suspend(); + layout([b, a, c]); + a.dragTranslate = b.dragTranslate = 0; + motion.update(true); + expect([a, b, c].every((row) => row.animations.length === 0)).toBe(true); + + layout([c, b, a]); + motion.update(true); + expectMove(c, 166); + expectMove(b, -83); + expectMove(a, -83); + }); + + it("does not carry a canceled drag's transformed position into the next move", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + motion.suspend(); + a.dragTranslate = 500; + b.dragTranslate = -83; + motion.update(false); + motion.suspend(); + a.dragTranslate = b.dragTranslate = 0; + motion.update(true); + expect(a.animate).not.toHaveBeenCalled(); + expect(b.animate).not.toHaveBeenCalled(); + + layout([b, a]); + motion.update(true); + expectMove(a, -83); + expectMove(b, 83); + }); + + it("retargets rapid changes from the current visual position", () => { + const [a, b, c] = [new TestRow("a", 99), new TestRow("b", 99), new TestRow("c", 99)]; + const { motion, layout } = fixture([a, b, c]); + motion.update(true); + layout([b, c, a]); + motion.update(true); + expectMove(a, -200); + const first = a.animations[0]!; + first.progress = 0.25; + + layout([b, a, c]); + motion.update(true); + expect(first.cancel).toHaveBeenCalledOnce(); + expectMove(a, -50); + first.finish(); + motion.suspend(); + expect(a.animations[1]!.cancel).toHaveBeenCalledOnce(); + }); + + it("keeps an uninterrupted movement when the layout position does not change", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + a.animations[0]!.progress = 0.5; + motion.update(true); + expect(a.animate).toHaveBeenCalledOnce(); + expect(a.animations[0]!.cancel).not.toHaveBeenCalled(); + }); + + it("cancels owned motion on suspension and never animates a disposed list", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + motion.suspend(); + expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); + expect(b.animations[0]!.cancel).toHaveBeenCalledOnce(); + motion.update(false); + layout([a, b]); + motion.update(true); + motion.dispose(); + expect(a.animations[1]!.cancel).toHaveBeenCalledOnce(); + layout([b, a]); + motion.update(true); + expect(a.animate).toHaveBeenCalledTimes(2); + }); + + it("fades a collapsed-shelf exit at its current visual box and a new wake in", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const fresh = new TestRow("new"); + a.setAttribute("data-thread-item", "a"); + a.children = [new TestRow("button")]; + a.children[0]!.setAttribute("id", "thread-control"); + a.children[0]!.setAttribute("data-testid", "thread-control"); + a.children[0]!.setAttribute("data-state", "open"); + const icon = new TestRow("provider icon"); + icon.namespaceURI = "http://www.w3.org/2000/svg"; + icon.setAttribute("id", "provider-mask"); + icon.setAttribute("mask", "url(#provider-mask)"); + a.children.push(icon); + const { motion, layout, parent } = fixture([a, b]); + motion.update(true); + layout([b, a]); + motion.update(true); + a.animations[0]!.progress = 0.5; + layout([b, fresh]); + motion.update(true); + expect(a.animations[0]!.cancel).toHaveBeenCalledOnce(); + expect(fresh.animate).toHaveBeenLastCalledWith([{ opacity: 0 }, { opacity: 1 }], { + duration: 150, + easing: "ease-out", + }); + const clone = a.clones[0]!; + expect(clone.style).toMatchObject({ + position: "absolute", + top: "49.5px", + left: "4px", + width: "260px", + height: "82px", + transform: "none", + pointerEvents: "none", + }); + expect(clone.inert).toBe(true); + expect(clone.attributes).toEqual([{ name: "aria-hidden", value: "true" }]); + expect(clone.children[0]!.attributes).toEqual([{ name: "data-state", value: "open" }]); + expect(clone.children[1]!.attributes).toEqual(icon.attributes); + expect(clone.animate).toHaveBeenCalledWith([{ opacity: 1 }, { opacity: 0 }], { + duration: 150, + easing: "ease-out", + }); + expect(parent.children.includes(clone)).toBe(true); + motion.update(true); + expect(clone.animations).toHaveLength(1); + expect(clone.clones).toHaveLength(0); + clone.animations[0]!.finish(); + expect(parent.children.includes(clone)).toBe(false); + }); + + it("clears exit clones on pickup and does not fade the release commit", () => { + const [a, b, c] = [new TestRow("a"), new TestRow("b"), new TestRow("c")]; + const { motion, layout, parent } = fixture([a, b]); + motion.update(false); + layout([b]); + motion.update(true); + const clone = a.clones[0]!; + motion.suspend(); + expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); + expect(parent.children.includes(clone)).toBe(false); + motion.update(false); + motion.suspend(); + layout([c]); + motion.update(true); + expect(b.clones).toHaveLength(0); + expect(c.animations).toHaveLength(0); + layout([c, a]); + motion.update(true); + expect(a.animate).toHaveBeenCalledWith([{ opacity: 0 }, { opacity: 1 }], { + duration: 150, + easing: "ease-out", + }); + motion.dispose(); + expect(a.animations.at(-1)!.cancel).toHaveBeenCalledOnce(); + }); + + it("carries entry opacity into a quick exit and removes artifacts on a silent update", () => { + const a = new TestRow("a"); + const marker = new TestRow("boundary", 0); + const { motion, layout, parent } = fixture([marker]); + motion.update(false); + layout([marker, a]); + motion.update(true); + a.animations[0]!.progress = 0.4; + layout([]); + motion.update(true); + expect(marker.clones).toHaveLength(0); + const clone = a.clones[0]!; + expect(clone.animate).toHaveBeenCalledWith([{ opacity: 0.4 }, { opacity: 0 }], { + duration: 150, + easing: "ease-out", + }); + motion.update(false); + expect(parent.children).toEqual([]); + expect(clone.animations[0]!.cancel).toHaveBeenCalledOnce(); + }); + + it("respects reduced motion while keeping the next baseline fresh", () => { + const a = new TestRow("a"); + const b = new TestRow("b"); + const { motion, layout, media } = fixture([a, b]); + motion.update(true); + media.matches = true; + layout([b, a]); + motion.update(true); + expect(a.animate).not.toHaveBeenCalled(); + media.matches = false; + layout([a, b]); + motion.update(true); + expectMove(a, 83); + expectMove(b, -83); + }); +}); diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts new file mode 100644 index 000000000000..065e22be1b30 --- /dev/null +++ b/apps/web/src/components/Sidebar.motion.ts @@ -0,0 +1,168 @@ +const motionTiming = { duration: 150, easing: "ease-out" }; + +type RowPosition = { top: number; left: number; width: number; height: number }; + +function progress(animation: Animation) { + return animation.playState === "finished" + ? 1 + : (animation.effect?.getComputedTiming().progress ?? 0); +} + +/** Animate rows between their layout positions. The list must be + * positioned so every direct child's offsetTop has the same origin. */ +export function createSidebarListMotion(parent: HTMLUListElement) { + let positions: Map | null = null; + let disposed = false; + const reducedMotion = parent.ownerDocument.defaultView?.matchMedia( + "(prefers-reduced-motion: reduce)", + ); + const running = new Map(); + const entering = new Map(); + const exiting = new Map(); + + const remainingOffset = (node: HTMLElement) => { + const current = running.get(node); + return current ? current.offset * (1 - progress(current.animation)) : 0; + }; + const clearFades = () => { + for (const animation of [...entering.values(), ...exiting.values()]) animation.cancel(); + for (const node of exiting.keys()) node.remove(); + entering.clear(); + exiting.clear(); + }; + const fadeOut = (node: HTMLElement, position: RowPosition) => { + if (position.height === 0) return; + // React owns the removed row; only a noninteractive copy stays for the fade. + const clone = node.cloneNode(true) as HTMLElement; + for (const element of [clone, ...clone.querySelectorAll("*")]) { + for (const attribute of Array.from(element.attributes)) { + if ( + (attribute.name === "id" && element.namespaceURI !== "http://www.w3.org/2000/svg") || + attribute.name === "data-thread-item" || + attribute.name === "data-thread-selection-safe" || + attribute.name === "data-testid" + ) { + element.removeAttribute(attribute.name); + } + } + } + clone.setAttribute("aria-hidden", "true"); + clone.inert = true; + Object.assign(clone.style, { + position: "absolute", + top: `${position.top + remainingOffset(node)}px`, + left: `${position.left}px`, + width: `${position.width}px`, + height: `${position.height}px`, + margin: "0", + boxSizing: "border-box", + contentVisibility: "visible", + transform: "none", + transition: "none", + pointerEvents: "none", + }); + parent.append(clone); + const entry = entering.get(node); + const animation = clone.animate( + [{ opacity: entry ? progress(entry) : 1 }, { opacity: 0 }], + motionTiming, + ); + exiting.set(clone, animation); + animation.addEventListener( + "finish", + () => { + clone.remove(); + exiting.delete(clone); + }, + { once: true }, + ); + }; + + const cancel = (node: HTMLElement) => { + running.get(node)?.animation.cancel(); + running.delete(node); + }; + const suspend = () => { + for (const node of running.keys()) cancel(node); + clearFades(); + positions = null; + }; + + return { + update(animate: boolean) { + if (disposed) return; + const next = new Map( + Array.from(parent.children) + .filter((node): node is HTMLElement => node instanceof HTMLElement && !exiting.has(node)) + .map((node) => [ + node, + { + top: node.offsetTop, + left: node.offsetLeft, + width: node.offsetWidth, + height: node.offsetHeight, + }, + ]), + ); + const shouldAnimate = animate && positions !== null && !reducedMotion?.matches; + if (!shouldAnimate) clearFades(); + else { + for (const [node, position] of positions!) { + if (!next.has(node)) fadeOut(node, position); + } + } + for (const [node, animation] of entering) { + if (!next.has(node)) { + animation.cancel(); + entering.delete(node); + } + } + for (const node of running.keys()) { + if (!shouldAnimate || !next.has(node)) cancel(node); + } + if (shouldAnimate) { + for (const [node, position] of next) { + const previousTop = positions?.get(node)?.top; + if (previousTop === undefined) { + if (position.height > 0) { + const animation = node.animate([{ opacity: 0 }, { opacity: 1 }], motionTiming); + entering.set(node, animation); + animation.addEventListener( + "finish", + () => { + if (entering.get(node) === animation) entering.delete(node); + }, + { once: true }, + ); + } + continue; + } + if (previousTop === position.top) continue; + // Computed progress includes the effect's easing. Only our own + // translate is carried forward; dnd-kit's transforms are never read. + const offset = previousTop + remainingOffset(node) - position.top; + cancel(node); + if (offset === 0) continue; + const animation = node.animate( + [{ transform: `translateY(${offset}px)` }, { transform: "translateY(0px)" }], + motionTiming, + ); + running.set(node, { animation, offset }); + animation.addEventListener( + "finish", + () => { + if (running.get(node)?.animation === animation) running.delete(node); + }, + { once: true }, + ); + } + } + positions = next; + }, + suspend, + dispose() { + suspend(); + disposed = true; + }, + }; +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a0d983976379..1a66dbdd9b5f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,20 +1,15 @@ -import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import * as Schema from "effect/Schema"; import { DndContext, PointerSensor, - closestCenter, useSensor, useSensors, type DragEndEvent, + type DragOverEvent, + type DragStartEvent, } from "@dnd-kit/core"; -import { - SortableContext, - arrayMove, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; +import { SortableContext, useSortable } from "@dnd-kit/sortable"; import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; import { CSS } from "@dnd-kit/utilities"; import { @@ -63,6 +58,7 @@ import { memo, useCallback, useEffect, + useLayoutEffect, useMemo, useReducer, useRef, @@ -77,6 +73,7 @@ import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, + type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { isElectron } from "../env"; import { @@ -137,7 +134,8 @@ import { cn } from "~/lib/utils"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { - animatePinnedLayoutChanges, + animateSidebarLayoutChanges, + applySidebarThreadDrop, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, @@ -148,14 +146,17 @@ import { isSidebarNestedLinkClick, isTrailingDoubleClick, orderItemsByPreferredIds, - planPinnedReorder, + planSidebarThreadDrop, reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, + resolveSidebarDropTarget, resolveSidebarThreadStatus, searchSidebarThreadsByTitle, shouldCreateNewThreadInCurrentProject, shouldRecedeSidebarThread, resolveWorkingStartedAt, + sidebarListItemId, + sidebarMarkerId, sortLogicalProjectsForSidebar, sortPinnedThreadsForSidebar, sortSettledThreadsForSidebar, @@ -163,8 +164,13 @@ import { useRetainedValue, useSidebarRowSubscriptionLease, useThreadJumpHintVisibility, + type SidebarListItem, + type SidebarListMarker, + type SidebarSection, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; +import { createSidebarCollisionDetection, createSidebarSortingStrategy } from "./Sidebar.drag"; +import { createSidebarListMotion } from "./Sidebar.motion"; import { ThreadWorktreeIndicator, prStatusIndicator, @@ -473,23 +479,25 @@ function SnoozePopoverButton(props: { ); } -// Subset of useSortable applied to a pinned card's root
  • . Listeners go -// on the whole card (no dedicated handle): the pointer sensor's distance +// Subset of useSortable applied to a thread row's root
  • . Listeners go +// on the whole row (no dedicated handle): the pointer sensor's distance // constraint keeps plain clicks working, and we skip dnd-kit's aria -// attributes since there is no keyboard sensor and the card body already +// attributes since there is no keyboard sensor and the row body already // carries its own button semantics. -type SortablePinnedRowBag = Pick< +type SortableThreadRowBag = Pick< ReturnType, "listeners" | "setNodeRef" | "transform" | "transition" | "isDragging" >; -function SortablePinnedThreadRow(props: { +function SortableThreadRow(props: { id: string; - children: (bag: SortablePinnedRowBag) => ReactNode; + disabled: boolean; + children: (bag: SortableThreadRowBag) => ReactNode; }) { const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: props.id, - animateLayoutChanges: animatePinnedLayoutChanges, + disabled: { draggable: props.disabled }, + animateLayoutChanges: animateSidebarLayoutChanges, }); return props.children({ listeners, setNodeRef, transform, transition, isDragging }); } @@ -499,6 +507,152 @@ function SortablePinnedThreadRow(props: { const draftSurfaceClassName = "bg-amber-400/[0.04] hover:bg-amber-400/[0.08]"; const draftPenClassName = "size-3 shrink-0 text-amber-600 dark:text-amber-300/80"; +// Structural list items — the section headers and the +// empty-section placeholders — take part in the sortable list so they shift +// with the rows and the gap can open on either side of them. They can't be +// picked up, and a marker is the sortable `over` when the pointer is on it, +// which resolveSidebarDropTarget turns into the section the gap sits in. +function SortableSidebarMarker(props: { + marker: SidebarListMarker; + className?: string; + children?: ReactNode; + "data-testid"?: string; +}) { + const { setNodeRef, transform, transition } = useSortable({ + id: sidebarMarkerId(props.marker), + disabled: { draggable: true }, + animateLayoutChanges: animateSidebarLayoutChanges, + }); + return ( +
  • + {props.children} +
  • + ); +} + +// Empty targets stay mounted before pickup so starting a drag never changes +// the list's measured positions. +function SidebarSectionPlaceholder(props: { + marker: "active-placeholder" | "settled-placeholder"; + label: string; + showHint: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.showHint ? props.label : null} + + ); +} + +// Boundary labels overlay the cards' padding during a drag. The measured +// marker stays empty, so showing a label never pushes a row out of the way. +function SidebarDragBoundary(props: { + marker: "pinned-header" | "pinned-divider"; + label: string; + hint: string | null; + visible: boolean; + isDropTarget: boolean; +}) { + return ( + + {props.visible ? ( +
    + + {props.label} + {props.hint ? {props.hint} : null} + + +
    + ) : null} +
    + ); +} + +// Shelf headers stay visible and keep their measured height while dragging. +function SidebarSectionHeader(props: { + marker: "snoozed-header" | "settled-header"; + label: string; + hint?: string | null; + isDropTarget?: boolean; + toggle: { expanded: boolean; onToggle: () => void }; +}) { + const snoozed = props.marker === "snoozed-header"; + const className = cn( + "flex h-full w-full items-center gap-2 rounded-md border border-dashed border-transparent px-2 text-left text-xs font-medium", + snoozed ? "text-blue-600 dark:text-blue-400" : "text-sidebar-muted-foreground/60", + props.isDropTarget && "border-primary/40 bg-primary/5 text-primary", + ); + const content = ( + <> + {props.label} + + {props.hint ? {props.hint} : null} + + + ); + return ( + + + + ); +} + // One unsent draft session the user has invested content in. Two lines, // nothing else: project name, then the typed prompt. All the draft's // settings (model, env mode, branch, worktree) still travel with it — @@ -752,10 +906,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { // rows. The marker can unpin the thread when the server supports pinning. pinningSupported: boolean; isPinned: boolean; - // Present only on pinned cards whose server supports reordering: dnd-kit - // sortable bag applied to the card root so the whole card drags (the + // Present on rows whose server supports every drop outcome: dnd-kit + // sortable bag applied to the row root so the whole row drags (the // pointer sensor's distance constraint keeps plain clicks working). - sortable?: SortablePinnedRowBag | undefined; + sortable?: SortableThreadRowBag | undefined; + dropSection: SidebarSection | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1151,6 +1306,41 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { !isSelected && "opacity-70 transition-opacity hover:opacity-100", ); + // dnd-kit props for the row root. Same bag on both variants: every row in + // the list translates around the gap as the drag passes it. + const sortable = props.sortable; + const sortableRootProps = sortable + ? { + ref: sortable.setNodeRef, + style: { + transform: CSS.Translate.toString(sortable.transform), + transition: sortable.transition, + // A zero-height boundary also makes dnd-kit scale the source to + // zero. Only projected peers use scaleY as a visibility sentinel. + visibility: + !sortable.isDragging && sortable.transform?.scaleY === 0 + ? ("hidden" as const) + : undefined, + }, + ...sortable.listeners, + } + : {}; + const dragDestination = + sortable?.isDragging && props.dropSection !== null ? ( + + + {props.dropSection === "pinned" + ? "Pinned" + : props.dropSection === "active" + ? "Active" + : props.dropSection === "settled" + ? "Settled" + : "Snoozed"} + + ) : null; const title = isRenaming ? ( - + - - {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( - // Snoozed rows show when they come BACK, not when they were - // last touched — the return ticket is the row's whole story. - - {props.snoozeWakeLabelText} - - ) : isWoke ? ( - // A wake can land straight in the settled tail (e.g. PR - // merged while snoozed); the signal must survive the trip. + {dragDestination ?? ( + + + {variantAction === "unsnooze" && props.snoozeWakeLabelText !== null ? ( + // Snoozed rows show when they come BACK, not when they were + // last touched — the return ticket is the row's whole story. + + {props.snoozeWakeLabelText} + + ) : isWoke ? ( + // A wake can land straight in the settled tail (e.g. PR + // merged while snoozed); the signal must survive the trip. + + + + Woke + + } + /> + Dismiss Woke notification + + ) : ( + + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} + + )} + + {variantAction === "unsnooze" ? ( + !props.snoozeSupported ? null : ( + + ) + ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - Woke - + aria-label="Un-settle thread" + onClick={handleUnsettleClick} + className={cn( + "pointer-events-none absolute inset-y-0 right-0 -mr-1 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-1.5 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:pointer-events-auto focus-visible:opacity-100 group-hover/sidebar-row:pointer-events-auto group-hover/sidebar-row:opacity-100", + isWoke && "group-hover/sidebar-row:static", + )} + /> } - /> - Dismiss Woke notification + > + + + Un-settle thread ) : ( - - {variantAction === "unsettle" - ? settledTimeLabel(thread) - : threadTimeLabel(thread)} - - )} - - {variantAction === "unsnooze" ? ( - !props.snoozeSupported ? null : ( - ) - ) : !props.settlementSupported ? null : variantAction === "unsettle" ? ( - - - } - > - - - Un-settle thread - - ) : ( - - )} - + )} + + )} {props.jumpLabel ? : null} {detailsTooltip} @@ -1429,26 +1625,16 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { const diff = latestTurnDiff(thread); - const sortable = props.sortable; return (
  • - +
  • {title} @@ -1834,8 +2022,10 @@ export default function Sidebar() { snoozeThread, unsnoozeThread, pinThread, + unpinThread, confirmAndUnpinThread, reorderPinnedThread, + reorderActiveThread, archiveThread, deleteThread, } = useThreadActions(); @@ -2196,13 +2386,27 @@ export default function Sidebar() { [openProjectSettings], ); - // Settled threads stay in the live shell stream (settled ≠ archived), so - // the partition works directly off live shells: no archived-snapshot - // merging, no optimistic holds. Archived threads remain hidden here — - // archive keeps its original "remove from sidebar" meaning. + // Keep a dropped row at its destination while its server applies the + // lifecycle command and any order-key writes. The next pickup waits for + // this hold so a second drop cannot replace an unconfirmed placement. + const [optimisticDrop, setOptimisticDrop] = useState<{ + readonly key: string; + readonly sourceSection: SidebarSection; + readonly section: "pinned" | "active" | "settled"; + readonly occurredAt: string; + readonly clearsSnooze: boolean; + /** Full destination order for pinned and active drops. */ + readonly order: readonly string[] | null; + /** Destination order keys before the drop, to recognize concurrent writes. */ + readonly keysAtDrop: ReadonlyMap; + /** The keys this drop writes (one per planned assignment). The + override holds until all of them appear in canonical state. */ + readonly assignedKeys: ReadonlyMap; + } | null>(null); const { pinnedThreads, - reorderablePinnedKeys, + draggableThreadKeys, + activeReorderableThreadKeys, activeThreads, snoozedThreads, settledThreads, @@ -2224,17 +2428,42 @@ export default function Sidebar() { const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; + const draggable = new Set(); + const activeReorderable = new Set(); for (const thread of visible) { + const capabilities = serverConfigs.get(thread.environmentId)?.environment.capabilities; // Threads on servers without the settlement capability (old server, // or descriptor not loaded yet) never classify as settled: the user // could neither un-settle nor pin them, so auto-settling them would // strand rows in a tail with no working affordances. - const supportsSettlement = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSettlement === true; - const supportsSnooze = - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; - // Snooze outranks settlement and pinning until the thread wakes. - if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + const supportsSettlement = capabilities?.threadSettlement === true; + const supportsSnooze = capabilities?.threadSnooze === true; + const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + if (capabilities?.threadActiveReorder === true) activeReorderable.add(threadKey); + // Older servers retain their existing drag actions. Active placement + // additionally requires its own ordering capability at the drop target. + if (capabilities?.threadPinning === true && capabilities.threadPinReorder === true) { + draggable.add(threadKey); + } + if (optimisticDrop?.key === threadKey) { + const projected = applySidebarThreadDrop( + thread, + optimisticDrop.section, + optimisticDrop.occurredAt, + optimisticDrop.assignedKeys.get(threadKey), + ); + (optimisticDrop.section === "pinned" + ? pinned + : optimisticDrop.section === "settled" + ? settled + : active + ).push( + optimisticDrop.clearsSnooze + ? projected + : { ...projected, snoozedAt: thread.snoozedAt, snoozedUntil: thread.snoozedUntil }, + ); + } else if (supportsSnooze && effectiveSnoozed(thread, { now: preciseNow })) { + // Snooze outranks settlement and pinning until the thread wakes. snoozed.push(thread); } else if (supportsSettlement && thread.settledOverride === "settled") { settled.push(thread); @@ -2249,18 +2478,27 @@ export default function Sidebar() { // Server capability only gates DRAGGING — it must not influence the // sort, or mixed-version fleets would render different pinned orders on // web and mobile from the same data. + const sortedPinned = sortPinnedThreadsForSidebar(pinned); + const sortedActive = sortThreadsForSidebar(active); return { - pinnedThreads: sortPinnedThreadsForSidebar(pinned), - reorderablePinnedKeys: new Set( - pinned - .filter( - (thread) => - serverConfigs.get(thread.environmentId)?.environment.capabilities.threadPinReorder === - true, - ) - .map((thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ), - activeThreads: sortThreadsForSidebar(active), + pinnedThreads: + optimisticDrop?.section !== "pinned" || optimisticDrop.order === null + ? sortedPinned + : orderItemsByPreferredIds({ + items: sortedPinned, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), + draggableThreadKeys: draggable, + activeReorderableThreadKeys: activeReorderable, + activeThreads: + optimisticDrop?.section !== "active" || optimisticDrop.order === null + ? sortedActive + : orderItemsByPreferredIds({ + items: sortedActive, + preferredIds: optimisticDrop.order, + getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + }), // Soonest wake first: "what comes back next" is the shelf's question. snoozedThreads: snoozed.toSorted( (left, right) => @@ -2270,7 +2508,7 @@ export default function Sidebar() { settledThreads: sortSettledThreadsForSidebar(settled), snoozeNow: preciseNow, }; - }, [nowMinute, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); + }, [nowMinute, optimisticDrop, scopedProjectKeys, serverConfigs, snoozeWakeTick, threads]); const threadSearchInputRef = useRef(null); const [threadSearchQuery, setThreadSearchQuery] = useState(""); @@ -2715,77 +2953,122 @@ export default function Sidebar() { }, [unsnoozeThread], ); - // Drag-to-reorder for the pinned block. A drop computes ONE fractional key - // for the moved thread and sends it to that thread's own server (see - // planPinnedReorder for the keyless-neighbor materialization case, which - // instead rewrites every key in the section). The optimistic order keeps - // the card where it was dropped until EVERY key the drop wrote is - // reflected in canonical state — a section rewrite is several sequential - // writes, and releasing on the first landed key would expose the - // half-written canonical order, reshuffling the block once per write. - // A failed write clears the override (the card snaps back) with a toast. - // A key we did NOT write landing (a concurrent client's reorder that must - // win) and ANY membership change (new pin, unpin, snooze/wake) also - // release it: the override can't say where members it never saw belong, - // and holding it would launder a stale order into later drags. - const pinnedDndSensors = useSensors( + const listMotionRef = useRef | null>(null); + const attachListMotionRef = useCallback((node: HTMLUListElement | null) => { + listMotionRef.current?.dispose(); + listMotionRef.current = node === null ? null : createSidebarListMotion(node); + listMotionRef.current?.update(false); + }, []); + + // Hold the chosen section and order until every key write arrives. This + // also covers first-time ordering, which assigns keys to keyless neighbors. + // A failed write, concurrent reorder, or membership change releases the hold. + const dndSensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), ); - const [optimisticPinnedOrder, setOptimisticPinnedOrder] = useState<{ - readonly order: readonly string[]; - /** pinOrderKey per thread as of the drop — the baseline that tells a - concurrent client's write apart from one of our own landing. */ - readonly keysAtDrop: ReadonlyMap; - /** The keys this drop writes (one per planned assignment). The - override holds until all of them appear in canonical state. */ - readonly assignedKeys: ReadonlyMap; + const [dragState, setDragState] = useState<{ + readonly activeKey: string; + readonly activeSection: SidebarSection; + readonly occurredAt: string; + readonly activationY: number | null; } | null>(null); - const orderedPinnedThreads = useMemo(() => { - if (optimisticPinnedOrder === null) return pinnedThreads; - return orderItemsByPreferredIds({ - items: pinnedThreads, - preferredIds: optimisticPinnedOrder.order, - getId: (thread) => scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - }); - }, [optimisticPinnedOrder, pinnedThreads]); + const [dragTargetSection, setDragTargetSection] = useState(null); + const sectionByThreadKey = useMemo(() => { + const map = new Map(); + const add = (list: readonly EnvironmentThreadShell[], section: SidebarSection) => { + for (const thread of list) { + map.set(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), section); + } + }; + add(pinnedThreads, "pinned"); + add(activeThreads, "active"); + add(snoozedThreads, "snoozed"); + add(settledThreads, "settled"); + return map; + }, [activeThreads, pinnedThreads, settledThreads, snoozedThreads]); + const pinnedKeys = useMemo( + () => + pinnedThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [pinnedThreads], + ); + const activeKeys = useMemo( + () => + activeThreads.map((thread) => + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + ), + [activeThreads], + ); useEffect(() => { - if (optimisticPinnedOrder === null) return; - const canonical = pinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), + if (optimisticDrop === null) return; + const canonicalByKey = new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread, + ]), ); - const canonicalKeys = canonical.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + const thread = canonicalByKey.get(optimisticDrop.key); + if (thread === undefined || thread.archivedAt !== null) { + setOptimisticDrop(null); + return; + } + const canonicalSection = effectiveSnoozed(thread, { now: new Date().toISOString() }) + ? "snoozed" + : thread.settledOverride === "settled" + ? "settled" + : thread.pinnedAt != null + ? "pinned" + : "active"; + if ( + canonicalSection !== optimisticDrop.sourceSection && + canonicalSection !== optimisticDrop.section + ) { + setOptimisticDrop(null); + return; + } + if (optimisticDrop.order === null) { + // Settle also emits unpin/unsnooze events. Wait for the entire move + // before releasing the projected fields and sort timestamps. + if ( + canonicalSection === optimisticDrop.section && + thread.pinnedAt == null && + (!optimisticDrop.clearsSnooze || thread.snoozedUntil == null) + ) { + setOptimisticDrop(null); + } + return; + } + if (canonicalSection !== optimisticDrop.section) return; + if (optimisticDrop.clearsSnooze && thread.snoozedUntil != null) return; + const destinationKeys = optimisticDrop.section === "pinned" ? pinnedKeys : activeKeys; + const canonicalDestination = destinationKeys.flatMap((key) => { + const canonical = canonicalByKey.get(key); + return canonical === undefined ? [] : [canonical]; + }); + const keyByThread = new Map( + canonicalDestination.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + (optimisticDrop.section === "pinned" ? thread.pinOrderKey : thread.activeOrderKey) ?? null, + ]), ); - // The override represents one drop against one snapshot of the world. - // Release it when the world moves on: membership changed (pin/unpin/ - // snooze/wake — the override can't say where members it never saw - // belong), a key changed to something we did NOT write (a concurrent - // client's reorder that must win), every key we wrote has landed, or - // canonical already matches. Releasing on the FIRST landed key instead - // of the last exposes the half-written order mid-materialization and - // the block visibly reshuffles once per write. + const heldOrder = optimisticDrop.order; + const heldKeys = new Set(heldOrder); const membershipChanged = - canonicalKeys.length !== optimisticPinnedOrder.order.length || - canonicalKeys.some((key) => !optimisticPinnedOrder.order.includes(key)); - const foreignKeyLanded = canonical.some((thread, index) => { - const threadKey = canonicalKeys[index]!; - const currentKey = thread.pinOrderKey ?? null; - if (currentKey === optimisticPinnedOrder.keysAtDrop.get(threadKey)) return false; - return currentKey !== optimisticPinnedOrder.assignedKeys.get(threadKey); + destinationKeys.length !== heldOrder.length || + destinationKeys.some((key) => !heldKeys.has(key)); + const foreignKeyLanded = destinationKeys.some((threadKey) => { + const currentKey = keyByThread.get(threadKey) ?? null; + if (currentKey === (optimisticDrop.keysAtDrop.get(threadKey) ?? null)) return false; + return currentKey !== optimisticDrop.assignedKeys.get(threadKey); }); - const currentKeyByThreadKey = new Map( - canonical.map((thread, index) => [canonicalKeys[index]!, thread.pinOrderKey ?? null]), - ); - const allAssignmentsLanded = [...optimisticPinnedOrder.assignedKeys].every( - ([threadKey, orderKey]) => currentKeyByThreadKey.get(threadKey) === orderKey, + const allAssignmentsLanded = [...optimisticDrop.assignedKeys].every( + ([threadKey, orderKey]) => keyByThread.get(threadKey) === orderKey, ); - const orderConfirmed = - !membershipChanged && - canonicalKeys.every((key, index) => key === optimisticPinnedOrder.order[index]); - if (membershipChanged || foreignKeyLanded || allAssignmentsLanded || orderConfirmed) { - setOptimisticPinnedOrder(null); + if (membershipChanged || foreignKeyLanded || allAssignmentsLanded) { + setOptimisticDrop(null); } - }, [optimisticPinnedOrder, pinnedThreads, reorderablePinnedKeys]); + }, [activeKeys, optimisticDrop, pinnedKeys, threads]); const attemptPin = useCallback( (threadRef: ScopedThreadRef) => { void (async () => { @@ -2827,71 +3110,338 @@ export default function Sidebar() { [confirmAndUnpinThread], ); - const handlePinnedDragEnd = useCallback( - (event: DragEndEvent) => { + const handleThreadDragStart = useCallback( + (event: DragStartEvent) => { const activeKey = String(event.active.id); - const overKey = event.over === null ? null : String(event.over.id); - if (overKey === null || activeKey === overKey) return; - const reorderable = orderedPinnedThreads.filter((thread) => - reorderablePinnedKeys.has(scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id))), - ); - const keys = reorderable.map((thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ); - const fromIndex = keys.indexOf(activeKey); - const toIndex = keys.indexOf(overKey); - if (fromIndex === -1 || toIndex === -1) return; - const newOrder = arrayMove([...keys], fromIndex, toIndex); - const threadByKey = new Map(reorderable.map((thread, index) => [keys[index]!, thread])); - const keysAtDrop = new Map( - reorderable.map((thread, index) => [keys[index]!, thread.pinOrderKey ?? null]), - ); - const assignments = planPinnedReorder({ - orderedIds: newOrder, - keysById: keysAtDrop, - movedId: activeKey, + const activeSection = sectionByThreadKey.get(activeKey); + if (activeSection === undefined) return; + // Stop normal section motion before dnd-kit measures the picked-up row. + listMotionRef.current?.suspend(); + setDragState({ + activeKey, + activeSection, + occurredAt: new Date().toISOString(), + activationY: + event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, }); - if (assignments.length === 0) return; - setOptimisticPinnedOrder({ - order: newOrder, - keysAtDrop, - assignedKeys: new Map( - assignments.map((assignment) => [assignment.id, assignment.orderKey]), - ), + setDragTargetSection(activeSection); + }, + [sectionByThreadKey], + ); + const handleThreadDragCancel = useCallback(() => { + listMotionRef.current?.suspend(); + setDragState(null); + setDragTargetSection(null); + }, []); + // Include every visible row in the measured order. Older servers disable + // pickup on their rows without changing where those rows render. + const sidebarListItems = useMemo((): readonly SidebarListItem[] => { + const rowsOf = ( + list: readonly EnvironmentThreadShell[], + section: SidebarSection, + ): SidebarListItem[] => + list.map((thread) => { + const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + return { kind: "thread", key, section }; + }); + if ( + pinnedThreads.length + + activeThreads.length + + snoozedThreads.length + + settledThreads.length === + 0 + ) { + return []; + } + const items: SidebarListItem[] = [{ kind: "marker", marker: "pinned-header" }]; + const pinnedRows = rowsOf(pinnedThreads, "pinned"); + items.push(...pinnedRows); + items.push({ kind: "marker", marker: "pinned-divider" }); + const activeRows = rowsOf(activeThreads, "active"); + if (activeRows.length === 0) { + items.push({ kind: "marker", marker: "active-placeholder" }); + } + items.push(...activeRows); + if (snoozedThreads.length > 0) { + items.push({ kind: "marker", marker: "snoozed-header" }); + items.push(...rowsOf(visibleSnoozedThreads, "snoozed")); + } + items.push({ kind: "marker", marker: "settled-header" }); + const settledRows = rowsOf(renderedSettledThreads, "settled"); + if (settledRows.length === 0) { + items.push({ kind: "marker", marker: "settled-placeholder" }); + } + items.push(...settledRows); + return items; + }, [ + activeThreads, + pinnedThreads, + renderedSettledThreads, + settledThreads.length, + snoozedThreads.length, + visibleSnoozedThreads, + ]); + const listMotionPaused = dragState !== null; + useLayoutEffect(() => { + // Drag release clears the baseline, so its commit cannot replay the + // sortable preview. Later thread actions can animate while writes settle. + // Draft navigation can reveal a frozen row without changing the draft count. + listMotionRef.current?.update( + !listMotionPaused && sidebarListItems.length + visibleDraftSessionCount > 0, + ); + }, [listMotionPaused, routeDraftIdForRows, sidebarListItems, visibleDraftSessionCount]); + const handleThreadDragOver = useCallback( + (event: DragOverEvent) => { + const target = event.over + ? resolveSidebarDropTarget(sidebarListItems, String(event.active.id), String(event.over.id)) + : null; + setDragTargetSection(target?.section ?? null); + }, + [sidebarListItems], + ); + const sortableIds = useMemo(() => sidebarListItems.map(sidebarListItemId), [sidebarListItems]); + const draggedSettledOrder = useMemo(() => { + const thread = dragState === null ? undefined : threadByKey.get(dragState.activeKey); + if (dragState === null || thread === undefined) return []; + const key = (candidate: EnvironmentThreadShell) => + scopedThreadKey(scopeThreadRef(candidate.environmentId, candidate.id)); + return sortSettledThreadsForSidebar([ + ...settledThreads.filter((candidate) => key(candidate) !== dragState.activeKey), + applySidebarThreadDrop(thread, "settled", dragState.occurredAt), + ]).map(key); + }, [dragState, settledThreads, threadByKey]); + const sidebarSortingStrategy = useMemo( + () => + createSidebarSortingStrategy({ + items: sidebarListItems, + settledOrder: draggedSettledOrder, + settledExpanded: settledShelfExpanded, + settledVisibleCount, + routeThreadKey, + snoozedThreadCount: snoozedThreads.length, + }), + [ + draggedSettledOrder, + routeThreadKey, + settledShelfExpanded, + settledVisibleCount, + sidebarListItems, + snoozedThreads.length, + ], + ); + // Hidden and filtered threads keep their keys. Reserve those slots without + // including the rows in the visible drop order or writing to them. + const { pinnedKeysById, activeKeysById } = useMemo( + () => ({ + pinnedKeysById: new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.pinOrderKey ?? null, + ]), + ), + activeKeysById: new Map( + threads.map((thread) => [ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), + thread.activeOrderKey ?? null, + ]), + ), + }), + [threads], + ); + const dndCollisionDetection = useMemo(() => { + if (dragState === null) return createSidebarCollisionDetection(() => true); + const source = threadByKey.get(dragState.activeKey); + if (source === undefined) return createSidebarCollisionDetection(() => false); + return createSidebarCollisionDetection( + (id) => { + const target = resolveSidebarDropTarget(sidebarListItems, dragState.activeKey, id); + if (target === null) return false; + return ( + planSidebarThreadDrop({ + activeKey: dragState.activeKey, + activeSection: dragState.activeSection, + activePinned: source.pinnedAt != null, + activeSettled: source.settledOverride === "settled", + supportsSettlement: + serverConfigs.get(source.environmentId)?.environment.capabilities.threadSettlement === + true, + target, + pinnedOrder: pinnedKeys, + pinnedKeysById, + reorderableKeys: draggableThreadKeys, + activeOrder: activeKeys, + activeKeysById, + activeReorderableKeys: activeReorderableThreadKeys, + }).kind !== "none" + ); + }, + { emptyPins: pinnedKeys.length === 0, activationY: dragState.activationY }, + ); + }, [ + activeKeysById, + pinnedKeysById, + serverConfigs, + activeKeys, + activeReorderableThreadKeys, + dragState, + draggableThreadKeys, + pinnedKeys, + sidebarListItems, + threadByKey, + ]); + const handleThreadDragEnd = useCallback( + (event: DragEndEvent) => { + listMotionRef.current?.suspend(); + setDragState(null); + setDragTargetSection(null); + const activeKey = String(event.active.id); + const activeSection = sectionByThreadKey.get(activeKey); + const target = + event.over === null + ? null + : resolveSidebarDropTarget(sidebarListItems, activeKey, String(event.over.id)); + const activeThread = threadByKey.get(activeKey); + if (activeSection === undefined || target === null || activeThread === undefined) return; + const threadRef = scopeThreadRef(activeThread.environmentId, activeThread.id); + const plan = planSidebarThreadDrop({ + activeKey, + activeSection, + activePinned: activeThread.pinnedAt != null, + activeSettled: activeThread.settledOverride === "settled", + supportsSettlement: + serverConfigs.get(activeThread.environmentId)?.environment.capabilities + .threadSettlement === true, + target, + pinnedOrder: pinnedKeys, + pinnedKeysById, + reorderableKeys: draggableThreadKeys, + activeOrder: activeKeys, + activeKeysById, + activeReorderableKeys: activeReorderableThreadKeys, }); + if (plan.kind === "none") return; + if (plan.kind === "settle" && settlingThreadKeysRef.current.has(activeKey)) return; + const assignments = + plan.kind === "pin" + ? [ + ...(plan.orderKey === undefined ? [] : [{ id: activeKey, orderKey: plan.orderKey }]), + ...plan.extraAssignments, + ] + : plan.kind === "reorder-pinned" || plan.kind === "move-active" + ? plan.assignments + : []; + const drop = { + key: activeKey, + sourceSection: activeSection, + section: target.section, + occurredAt: new Date().toISOString(), + clearsSnooze: + plan.kind === "pin" || + plan.kind === "settle" || + (plan.kind === "move-active" && plan.unsnooze), + order: plan.kind === "settle" ? null : plan.order, + keysAtDrop: target.section === "active" ? activeKeysById : pinnedKeysById, + assignedKeys: new Map(assignments.map(({ id, orderKey }) => [id, orderKey])), + }; + setOptimisticDrop(drop); void (async () => { - // Sequential, stop on first failure. There is deliberately no - // rollback: every key write is a complete, valid placement on its - // own, so a partial materialization leaves a sensible order (and - // the next drag repairs the rest) — unwinding writes across - // servers would trade that for real inconsistency windows. - for (const assignment of assignments) { - const thread = threadByKey.get(assignment.id); - if (thread === undefined) continue; - const result = await reorderPinnedThread( - scopeThreadRef(thread.environmentId, thread.id), - assignment.orderKey, - ); - if (result._tag === "Failure") { - // Any failure — interrupted included — releases the override: - // a key that never lands would otherwise hold it until some - // unrelated world change came along. - setOptimisticPinnedOrder(null); - if (isAtomCommandInterrupted(result)) return; + const run = async ( + operation: Promise>, + title: string, + ) => { + const result = await operation; + if (result._tag === "Success") return true; + // A late failure must not cancel a newer drag's preview. + setOptimisticDrop((current) => (current === drop ? null : current)); + if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", - title: "Failed to reorder pinned threads", + title, description: error instanceof Error ? error.message : "An error occurred.", }), ); + } + return false; + }; + switch (plan.kind) { + case "settle": { + settlingThreadKeysRef.current.add(activeKey); + const navigateAfterSettle = planForwardNavigation(activeKey); + const settled = await run(settleThread(threadRef), "Failed to settle thread").finally( + () => settlingThreadKeysRef.current.delete(activeKey), + ); + if (settled && routeThreadKeyRef.current === activeKey) navigateAfterSettle?.(); return; } + case "move-active": + // The drag expresses unpin intent; button/menu confirmation is unchanged. + if (plan.unpin && !(await run(unpinThread(threadRef), "Failed to unpin thread"))) + return; + if ( + plan.unsettle && + !(await run(unsettleThread(threadRef), "Failed to un-settle thread")) + ) + return; + if (plan.unsnooze && !(await run(unsnoozeThread(threadRef), "Failed to wake thread"))) + return; + break; + case "pin": + if ( + !(await run( + pinThread( + threadRef, + plan.orderKey === undefined ? {} : { orderKey: plan.orderKey }, + ), + "Failed to pin thread", + )) + ) + return; + break; + case "reorder-pinned": + break; + } + // Stop on failure; each successful key write remains a valid placement. + const keyWrites = plan.kind === "pin" ? plan.extraAssignments : plan.assignments; + for (const assignment of keyWrites) { + const thread = threadByKey.get(assignment.id); + if (thread === undefined) continue; + if ( + !(await run( + (plan.kind === "move-active" ? reorderActiveThread : reorderPinnedThread)( + scopeThreadRef(thread.environmentId, thread.id), + assignment.orderKey, + ), + plan.kind === "move-active" + ? "Failed to reorder active threads" + : "Failed to reorder pinned threads", + )) + ) + return; } })(); }, - [orderedPinnedThreads, reorderPinnedThread, reorderablePinnedKeys], + [ + activeKeysById, + pinnedKeysById, + serverConfigs, + activeKeys, + activeReorderableThreadKeys, + draggableThreadKeys, + pinThread, + pinnedKeys, + planForwardNavigation, + reorderPinnedThread, + reorderActiveThread, + sectionByThreadKey, + settleThread, + sidebarListItems, + threadByKey, + unpinThread, + unsettleThread, + unsnoozeThread, + ], ); // One snooze per thread at a time — same double-dispatch guard as settle. const snoozingThreadKeysRef = useRef(new Set()); @@ -3535,11 +4085,6 @@ export default function Sidebar() { updateThreadJumpHintsVisibility(shouldShowJumpHintsNow); }, [shouldShowJumpHintsNow, updateThreadJumpHintsVisibility]); - const attachListAutoAnimateRef = useCallback((node: HTMLUListElement | null) => { - if (!node) return; - autoAnimate(node, { duration: 150, easing: "ease-out" }); - }, []); - // New thread defaults to the project you're in (active thread's project, // falling back to the top project) — same resolution the command palette // uses. The command palette already offers a "New thread in..." submenu @@ -3929,281 +4474,298 @@ export default function Sidebar() { closeDelay={0} timeout={400} > -
      - {(() => { - const renderThreadRow = ( - thread: EnvironmentThreadShell, - section: "pinned" | "active" | "snoozed" | "settled", - sortable?: SortablePinnedRowBag, - ) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - // Settled and snoozed are the ONLY things that collapse a - // row: every other thread is a full card. Density comes - // from users (or the auto rules) actually parking work, - // not from the sidebar second-guessing what still matters. - const isCard = section === "active" || section === "pinned"; - const rowVariant = isCard ? "card" : "slim"; - return ( - - ); - }; - // Draft block above everything, then the pinned block: - // full cards above the inbox, closed by a thin divider (the - // pin glyphs carry the meaning, so no header text). Both - // vanish entirely at count 0. - // Pinned rows render in the one shared pinned order; only - // reorder-capable rows register as sortable (legacy-server - // pins render in place as plain rows). - const items: ReactNode[] = [ - , - pinnedThreads.length > 0 ? ( -
    • - - - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), - ) - .filter((threadKey) => reorderablePinnedKeys.has(threadKey))} - strategy={verticalListSortingStrategy} + + +
        + {(() => { + const renderThreadRowInner = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + sortable?: SortableThreadRowBag, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + // Settled and snoozed are the ONLY things that collapse a + // row: every other thread is a full card. Density comes + // from users (or the auto rules) actually parking work, + // not from the sidebar second-guessing what still matters. + const isCard = section === "active" || section === "pinned"; + const rowVariant = isCard ? "card" : "slim"; + return ( + + ); + }; + const renderThreadRow = ( + thread: EnvironmentThreadShell, + section: SidebarSection, + ) => { + const threadKey = scopedThreadKey( + scopeThreadRef(thread.environmentId, thread.id), + ); + return ( + -
          - {orderedPinnedThreads.map((thread) => { - const threadKey = scopedThreadKey( - scopeThreadRef(thread.environmentId, thread.id), - ); - if (!reorderablePinnedKeys.has(threadKey)) { - return renderThreadRow(thread, "pinned"); + {(bag) => renderThreadRowInner(thread, section, bag)} + + ); + }; + const from = dragState?.activeSection ?? null; + const previewPinnedCount = + pinnedThreads.length + + (from !== "pinned" && dragTargetSection === "pinned" ? 1 : 0) - + (from === "pinned" && + dragTargetSection !== null && + dragTargetSection !== "pinned" + ? 1 + : 0); + const activeHint = + from === "pinned" + ? "Drop to unpin" + : from === "settled" + ? "Drop to un-settle" + : from === "snoozed" + ? "Drop to wake" + : null; + const items: ReactNode[] = [ + , + ]; + for (const item of sidebarListItems) { + if (item.kind === "thread") { + items.push(renderThreadRow(threadByKey.get(item.key)!, item.section)); + continue; + } + switch (item.marker) { + case "pinned-header": + items.push( + , + ); + break; + case "pinned-divider": + items.push( + 0} + />, + ); + break; + case "active-placeholder": + items.push( + , + ); + break; + case "snoozed-header": + items.push( + - {(bag) => renderThreadRow(thread, "pinned", bag)} - - ); - })} -
        - - - - ) : null, - ]; - if (pinnedThreads.length > 0) { - items.push( -
      • , - ); - } - for (const thread of activeThreads) { - items.push(renderThreadRow(thread, "active")); - } - // Snoozed shelf: between the inbox and Settled — out of the - // way, never gone. The header always renders while anything - // is snoozed (the count is the whole footprint when - // collapsed); rows only when expanded. Vanishes entirely at - // count 0. - if (snoozedThreads.length > 0) { - items.push( -
      • - -
      • , - ); - for (const thread of visibleSnoozedThreads) { - items.push(renderThreadRow(thread, "snoozed")); - } - } - if (settledThreads.length > 0) { - items.push( -
      • + toggle={{ + expanded: snoozedShelfExpanded, + onToggle: toggleSnoozedShelf, + }} + />, + ); + break; + case "settled-header": + items.push( + , + ); + break; + case "settled-placeholder": + items.push( + , + ); + break; + } + } + return items; + })()} + {settledShelfExpanded && hiddenSettledCount > 0 ? ( +
      • -
      • , - ); - } - for (const thread of renderedSettledThreads) { - items.push(renderThreadRow(thread, "settled")); - } - return items; - })()} - {settledShelfExpanded && hiddenSettledCount > 0 ? ( -
      • - -
      • - ) : null} -
      +
    • + ) : null} +
    + + ) : null} {!isSearchingThreads && diff --git a/apps/web/src/hooks/useThreadActions.ts b/apps/web/src/hooks/useThreadActions.ts index 9b162bc8cce3..cefe81a7fa09 100644 --- a/apps/web/src/hooks/useThreadActions.ts +++ b/apps/web/src/hooks/useThreadActions.ts @@ -25,6 +25,7 @@ import { readLocalApi } from "../localApi"; import { readEnvironmentSupportsPinning, readEnvironmentSupportsPinReorder, + readEnvironmentSupportsActiveReorder, readEnvironmentSupportsSettlement, readEnvironmentSupportsSnooze, readEnvironmentThreadRefs, @@ -124,6 +125,18 @@ export class ThreadPinReorderUnsupportedError extends Schema.TaggedErrorClass()( + "ThreadActiveReorderUnsupportedError", + { + environmentId: EnvironmentId, + threadId: ThreadId, + }, +) { + override get message(): string { + return "Update this environment's server to reorder active threads."; + } +} + export async function requestThreadUnpinConfirmation(input: { enabled: boolean; title: string; @@ -185,6 +198,9 @@ export function useThreadActions() { const reorderPinnedThreadMutation = useAtomCommand(threadEnvironment.reorderPin, { reportFailure: false, }); + const reorderActiveThreadMutation = useAtomCommand(threadEnvironment.reorderActive, { + reportFailure: false, + }); const snoozeThreadMutation = useAtomCommand(threadEnvironment.snooze, { reportFailure: false, }); @@ -631,6 +647,26 @@ export function useThreadActions() { [reorderPinnedThreadMutation], ); + const reorderActiveThread = useCallback( + async (target: ScopedThreadRef, orderKey: string) => { + if (!readEnvironmentSupportsActiveReorder(target.environmentId)) { + return AsyncResult.failure( + Cause.fail( + new ThreadActiveReorderUnsupportedError({ + environmentId: target.environmentId, + threadId: target.threadId, + }), + ), + ); + } + return reorderActiveThreadMutation({ + environmentId: target.environmentId, + input: { threadId: target.threadId, orderKey }, + }); + }, + [reorderActiveThreadMutation], + ); + const snoozeThread = useCallback( async (target: ScopedThreadRef, snoozedUntil: string) => { // Version skew: never send the command to a server that predates it. @@ -729,6 +765,7 @@ export function useThreadActions() { unpinThread, confirmAndUnpinThread, reorderPinnedThread, + reorderActiveThread, }), [ archiveThread, @@ -737,6 +774,7 @@ export function useThreadActions() { deleteThread, pinThread, reorderPinnedThread, + reorderActiveThread, settleThread, snoozeThread, unarchiveThread, diff --git a/apps/web/src/lib/threadSort.ts b/apps/web/src/lib/threadSort.ts index 2644ea67adec..7785bceaac73 100644 --- a/apps/web/src/lib/threadSort.ts +++ b/apps/web/src/lib/threadSort.ts @@ -1,5 +1,4 @@ export { - activeThreadAnchorTimestampMs, getLatestThreadForProject, getThreadSortTimestamp, resolveSettledThreadTimestamp, diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index deb4948a0f5a..d9610e20717f 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -229,6 +229,13 @@ export function readEnvironmentSupportsPinReorder(environmentId: EnvironmentId): ); } +export function readEnvironmentSupportsActiveReorder(environmentId: EnvironmentId): boolean { + return ( + appAtomRegistry.get(environmentServerConfigsAtom).get(environmentId)?.environment.capabilities + .threadActiveReorder === true + ); +} + export function readEnvironmentThreadRefs( environmentId: EnvironmentId, ): ReadonlyArray { diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 0a2e7aebad76..5168041471d3 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -22,12 +22,40 @@ worktree**, each background submission creates its own worktree. ## Pin and reorder threads -Pin a thread from its menu to keep it above your active work. Drag pinned threads -to reorder them on web and desktop, or use **Move up** and **Move down** on mobile. -The order syncs across devices. +Pin a thread from its menu to keep it above your active work. Pinning does not prevent automatic settlement. Settling a thread removes its pin. +On web and desktop, drag a thread between sections to change its state. Drag a thread up into +the pinned section to pin it at the spot you drop it; drag a pinned thread down into the active +list to unpin it. Dragging a thread onto the **Settled** header settles it, and dragging a settled +thread into the active list un-settles it. A snoozed thread can be dragged out of the snoozed +shelf, which wakes it, but threads cannot be dragged into the shelf because snoozing needs a wake +time. Dragging a pinned thread out of the pinned section does not ask for unpin confirmation. +Pinned and active boundary labels appear only while dragging, without moving the rows. The +destination boundary highlights and the thread shows which section it will land in. When there +are no pins, drag to the top edge to pin a thread. Drop instructions also appear for empty sections +and a collapsed settled shelf. + +Drag within the pinned or active section to change its order. Other rows slide aside to show the +spot where the thread will land. Drops into either section keep the position you choose. On +mobile, open a pinned or active thread's menu and choose **Move up** or **Move down**. The server +saves the order, so it survives a refresh and appears on your other connected devices. + +On web and desktop, the list also animates section changes made with thread actions such as +**Pin**, **Settle**, and **Snooze**. These transitions respect your system's reduced-motion +preference. While dragging, rows follow the insertion gap without replaying a second transition +after the drop. + +New threads appear above the active threads you have arranged. Settling clears a thread's active +position, so using **Un-settle** returns it to the top. Pinning and snoozing preserve its active +position until you move it again. Thread activity does not change the order. The settled shelf +continues to use settlement time. + +If dragging is unavailable for one environment, update the T3 Code server running in that +environment. Pinned and active reordering require server support. Threads from older servers keep +their default order until the server is updated. + ## Settle finished work Choose **Settle thread** from its menu to move finished work out of the active list diff --git a/packages/client-runtime/src/state/threadSort.ts b/packages/client-runtime/src/state/threadSort.ts index f06c95919554..3a4a9d284a18 100644 --- a/packages/client-runtime/src/state/threadSort.ts +++ b/packages/client-runtime/src/state/threadSort.ts @@ -107,7 +107,7 @@ export function getThreadSortTimestamp( * top instead of sinking back to its creation-order slot. Shared by web and * mobile so both render the same order. Malformed timestamps sink to 0. */ -export function activeThreadAnchorTimestampMs(thread: { +function activeThreadAnchorTimestampMs(thread: { readonly createdAt: string; readonly unsettledAt?: string | null | undefined; }): number { From 9a47c7bd40522977c7df022a5b64759aae7b58e0 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 6 Sep 2026 11:10:14 -0700 Subject: [PATCH 228/320] feat(web): simplify sidebar drag destination cues (#9750) --- apps/web/src/components/Sidebar.tsx | 95 +++++++++++++---------------- docs/user/thread-sidebar.md | 8 ++- 2 files changed, 47 insertions(+), 56 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 1a66dbdd9b5f..63c7deca4c5e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -568,7 +568,6 @@ function SidebarSectionPlaceholder(props: { function SidebarDragBoundary(props: { marker: "pinned-header" | "pinned-divider"; label: string; - hint: string | null; visible: boolean; isDropTarget: boolean; }) { @@ -587,7 +586,6 @@ function SidebarDragBoundary(props: { )} > {props.label} - {props.hint ? {props.hint} : null} void }; }) { @@ -624,7 +621,6 @@ function SidebarSectionHeader(props: { props.isDropTarget && "bg-primary/30", )} /> - {props.hint ? {props.hint} : null} | null; // Compact wake countdown ("2h") for rows in the snoozed shelf. snoozeWakeLabelText: string | null; // When a snooze ended (timer or early wake); drives the Woke pill until @@ -1331,14 +1327,13 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { role="status" className="pointer-events-none ml-auto inline-flex h-5 shrink-0 items-center gap-1 rounded-sm border border-primary/30 bg-sidebar px-1.5 text-[11px] font-medium text-primary" > + Move to {props.dropSection === "pinned" ? "Pinned" : props.dropSection === "active" ? "Active" - : props.dropSection === "settled" - ? "Settled" - : "Snoozed"} + : "Settled"} ) : null; @@ -1442,31 +1437,32 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { Unsent draft ) : null; - const pinIndicator = props.isPinned ? ( - props.pinningSupported ? ( - - - } - > - - - Unpin thread - - ) : ( - - ) - ) : null; + const pinIndicator = + props.isPinned && !sortable?.isDragging ? ( + props.pinningSupported ? ( + + + } + > + + + Unpin thread + + ) : ( + + ) + ) : null; if (variant === "slim") { return ( @@ -1526,7 +1522,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { remain visible AND clickable while the row is hovered. Only the time/jump label yields to the settle affordance. */} {prBadge} - {dragDestination ?? ( + {sortable?.isDragging ? ( + dragDestination + ) : (