From 689763a603a72f38c451f1b9409e187216932386 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 22:05:36 -0700 Subject: [PATCH 001/317] fix(mobile): isolate file tree state by workspace (#24270) --- .../src/features/files/FileTreeBrowser.tsx | 50 +- .../features/files/ThreadFilesRouteScreen.tsx | 27 +- .../files/thread-file-navigator-pane.tsx | 10 +- .../files/useFileTreeEntries.test.tsx | 721 ++++++++++++++++++ .../src/features/files/useFileTreeEntries.ts | 519 +++++++++++-- apps/mobile/src/state/query.ts | 5 + apps/mobile/vite.config.ts | 1 + 7 files changed, 1222 insertions(+), 111 deletions(-) create mode 100644 apps/mobile/src/features/files/useFileTreeEntries.test.tsx diff --git a/apps/mobile/src/features/files/FileTreeBrowser.tsx b/apps/mobile/src/features/files/FileTreeBrowser.tsx index c08f21ad49836..93e238822c4f3 100644 --- a/apps/mobile/src/features/files/FileTreeBrowser.tsx +++ b/apps/mobile/src/features/files/FileTreeBrowser.tsx @@ -32,15 +32,6 @@ function cachedFileTree(entries: ReadonlyArray): ReadonlyArray { - const parts = path.split("/").filter(Boolean); - const ancestors: string[] = []; - for (let index = 1; index < parts.length; index += 1) { - ancestors.push(parts.slice(0, index).join("/")); - } - return ancestors; -} - const FileTreeRow = function FileTreeRow(props: { readonly item: VisibleFileTreeNode; readonly selected: boolean; @@ -107,6 +98,11 @@ const FileTreeRow = function FileTreeRow(props: { }; export function FileTreeBrowser(props: { + readonly revealSelection: (path: string | null) => void; + readonly expandedPaths: ReadonlySet; + readonly setExpandedPaths: ( + update: (current: ReadonlySet) => ReadonlySet, + ) => void; readonly entries: ReadonlyArray; readonly error: string | null; readonly isPending: boolean; @@ -116,12 +112,12 @@ export function FileTreeBrowser(props: { readonly searchTruncated: boolean; readonly selectedPath: string | null; readonly loadingDirectories: ReadonlySet; - readonly onLoadDirectory: (path: string) => void; + readonly onLoadDirectory: (path: string, retry?: boolean) => void; readonly onPreviewFile?: (path: string) => void; readonly onRefresh: () => void; readonly onSelectFile: (path: string) => void; }) { - const [expandedPaths, setExpandedPaths] = useState>(() => new Set()); + const { expandedPaths, setExpandedPaths } = props; const [pendingSelection, setPendingSelection] = useState<{ readonly path: string; readonly selectedPathAtPress: string | null; @@ -149,27 +145,21 @@ export function FileTreeBrowser(props: { searchResultPaths: props.searchResultPaths, }); + const { revealSelection } = props; useEffect(() => { - if (!controlledSelectedPath) { - return; - } - // oxlint-disable-next-line react/set-state-in-effect -- An externally selected file expands its ancestor folders; the user can collapse them afterwards. - setExpandedPaths((current) => { - const ancestors = ancestorPaths(controlledSelectedPath); - if (ancestors.every((ancestor) => current.has(ancestor))) { - return current; - } - const next = new Set(current); - for (const ancestor of ancestors) { - next.add(ancestor); - } - return next; - }); - }, [controlledSelectedPath]); + revealSelection(controlledSelectedPath); + }, [controlledSelectedPath, revealSelection]); useEffect(() => { - for (const path of expandedPaths) onLoadDirectory(path); - }, [expandedPaths, onLoadDirectory]); + const visit = (nodes: ReadonlyArray) => { + for (const node of nodes) { + if (node.kind !== "directory" || !expandedPaths.has(node.path)) continue; + onLoadDirectory(node.path); + visit(node.children); + } + }; + visit(tree); + }, [tree, expandedPaths, onLoadDirectory]); useEffect( () => () => { @@ -181,7 +171,7 @@ export function FileTreeBrowser(props: { ); const toggleDirectory = (path: string, expand: boolean) => { - if (expand) onLoadDirectory(path); + if (expand) onLoadDirectory(path, true); setExpandedPaths((current) => { const next = new Set(current); if (next.has(path)) { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 3f2255a56b138..e1b4ac2a26d9d 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -452,7 +452,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { const { canReadFiles } = fileAccess; const entriesQuery = useFileTreeEntries({ environmentId, - cwd: !canReadFiles || hasFileInspector ? null : cwd, + cwd: canReadFiles && !hasFileInspector ? cwd : null, searchQuery, }); const handleReturnToThread = () => { @@ -565,6 +565,9 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { { + entriesQuery.selectPath(path); + handleSelectFile(path); + }} /> @@ -767,7 +773,10 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { headerInset={headerInset} projectName={directoryPath ?? projectName} selectedPath={directoryPath === null ? relativePath : null} - onSelectFile={handleSelectFile} + onSelectFile={(path) => { + if (directoryPath !== null) entriesQuery.selectPath(path); + handleSelectFile(path); + }} /> ) : undefined; const safeAreaInsets = useSafeAreaInsets(); @@ -945,6 +954,9 @@ export function ThreadFileScreen(props: ThreadFileRouteScreenProps) { { + entriesQuery.selectPath(path); + handleSelectFile(path); + }} /> diff --git a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx index 5f3deb6089c4b..d10746616d70f 100644 --- a/apps/mobile/src/features/files/thread-file-navigator-pane.tsx +++ b/apps/mobile/src/features/files/thread-file-navigator-pane.tsx @@ -87,6 +87,9 @@ export function ThreadFileNavigatorPane(props: { const fileTree = ( { + entriesQuery.selectPath(path); + props.onSelectFile(path); + }} /> ); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.test.tsx b/apps/mobile/src/features/files/useFileTreeEntries.test.tsx new file mode 100644 index 0000000000000..8d707fd379a83 --- /dev/null +++ b/apps/mobile/src/features/files/useFileTreeEntries.test.tsx @@ -0,0 +1,721 @@ +import { + EnvironmentId, + ProjectListEntriesError, + type ProjectEntry, + type ProjectListEntriesResult, +} from "@t3tools/contracts"; +import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/reactivity"; +import { act, useLayoutEffect, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; + +type Query = { + readonly environmentId: EnvironmentId; + readonly input: { + readonly cwd: string; + readonly query?: string; + readonly directoryPath?: string; + }; +}; +type Result = + | { + readonly _tag: "Success"; + readonly value: ProjectListEntriesResult; + readonly timestamp: number; + } + | { readonly _tag: "Failure"; readonly cause: Cause.Cause }; +type QueryState = { + success: AsyncResult.Success | null; + data: ProjectListEntriesResult; + error: string | null; + failure: ProjectListEntriesError | null; + isPending: boolean; + refresh: () => void; +}; + +const rootEntries: ProjectEntry[] = [{ kind: "directory", path: "src" }]; +const firstTarget = { cwd: "/first", environmentId: EnvironmentId.make("first") }; + +const mocks = vi.hoisted(() => { + const createState = () => ({ + requests: [] as { + readonly query: Query; + readonly signal?: AbortSignal; + readonly resolve: (result: Result) => void; + }[], + responseTimestamp: 0, + reconnect: () => {}, + rootQuery: null as unknown as QueryState, + searchQuery: null as unknown as QueryState, + results: new WeakMap< + object, + { + data: ProjectListEntriesResult; + error: string | null; + pending: boolean; + result: AsyncResult.AsyncResult; + } + >(), + }); + return { createState, state: createState() }; +}); + +vi.mock("@t3tools/client-runtime/state/runtime", () => ({ + executeAtomQuery: (_registry: unknown, query: Query, options: { signal?: AbortSignal }) => + new Promise((resolve) => + mocks.state.requests.push({ query, signal: options.signal, resolve }), + ), +})); +vi.mock("../../state/atom-registry", () => ({ appAtomRegistry: { refresh: () => {} } })); +vi.mock("../../state/projects", () => ({ + projectEnvironment: { + listEntries: (query: Query) => query, + searchEntries: (query: Query) => query, + }, +})); +vi.mock("../../connection/catalog", () => ({ + environmentCatalog: { sessionGenerationAtom: () => ({}) }, +})); +vi.mock("../../state/queries", () => ({ useDebouncedValue: (value: string) => value })); +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: Partial) => { + const state = mocks.state; + const query = atom.input?.query === undefined ? state.rootQuery : state.searchQuery; + if (query.success) return query.success; + const cached = state.results.get(query); + if ( + cached && + cached.data === query.data && + cached.error === query.error && + cached.pending === query.isPending + ) { + return cached.result; + } + const result = query.error + ? AsyncResult.fail(query.failure ?? new Error(query.error), { + previousSuccess: Option.some(AsyncResult.success(query.data)), + }) + : AsyncResult.success(query.data, { + waiting: query.isPending, + timestamp: ++state.responseTimestamp, + }); + state.results.set(query, { + data: query.data, + error: query.error, + pending: query.isPending, + result, + }); + return result; + }, + useAtomRefresh: () => mocks.state.rootQuery.refresh, + useAtomMount: () => {}, + useAtomSubscribe: (_atom: unknown, callback: () => void) => { + mocks.state.reconnect = callback; + }, +})); + +const { Children, Nothing } = vi.hoisted(() => ({ + Children: ({ children }: { children?: ReactNode }) =>
{children}
, + Nothing: () => null, +})); +vi.mock("react-native", () => ({ + Platform: { OS: "ios" }, + View: Children, + RefreshControl: Nothing, + Pressable: ({ + children, + onPress, + accessibilityLabel, + }: { + children?: ReactNode; + onPress: () => void; + accessibilityLabel?: string; + }) => ( + + ), +})); +vi.mock("react-native-safe-area-context", () => ({ useSafeAreaInsets: () => ({ top: 0 }) })); +vi.mock("../../native/NativeScrollViewMarker", () => ({ NativeScrollView: Nothing })); +vi.mock("../../native/native-layout-metrics", () => ({ useNativeColumnLayoutMetrics: () => null })); +vi.mock("@legendapp/list/react-native", () => ({ + LegendList: (props: { + data: ReadonlyArray; + keyExtractor: (item: T) => string; + renderItem: (info: { item: T }) => ReactNode; + ListHeaderComponent?: ReactNode; + ListEmptyComponent?: ReactNode; + }) => ( +
+ {props.ListHeaderComponent} + {props.data.length === 0 + ? props.ListEmptyComponent + : props.data.map((item) => ( +
{props.renderItem({ item })}
+ ))} +
+ ), +})); +vi.mock("../../components/ui/Button", () => ({ + Button: ({ + label, + onPress, + disabled, + }: { + label: string; + onPress: () => void; + disabled?: boolean; + }) => ( + + ), +})); +vi.mock("../../components/ui/AppActivityIndicator", () => ({ AppActivityIndicator: Nothing })); +vi.mock("../../components/ui/AppSymbol", () => ({ SymbolView: Nothing })); +vi.mock("../../components/ui/AppText", () => ({ AppText: Children })); +vi.mock("../../components/PierreEntryIcon", () => ({ PierreEntryIcon: Nothing })); +vi.mock("../../lib/cn", () => ({ cn: () => "" })); + +import { FileTreeBrowser } from "./FileTreeBrowser"; +import { useFileTreeEntries } from "./useFileTreeEntries"; + +type Hook = typeof useFileTreeEntries; +type Input = Parameters[0]; + +function setup(withBrowser = false) { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + const state = mocks.createState(); + mocks.state = state; + const rootQuery: QueryState = { + success: null, + data: { entries: rootEntries, truncated: false }, + error: null, + failure: null, + isPending: false, + refresh: vi.fn(), + }; + state.rootQuery = rootQuery; + const searchQuery: QueryState = { ...rootQuery, data: { entries: [], truncated: false } }; + state.searchQuery = searchQuery; + const { requests } = state; + const container = document.body.appendChild(document.createElement("div")); + const root = createRoot(container); + let current: ReturnType; + let renders = 0; + let controlledSelection: string | undefined; + function Probe(input: Input) { + if (++renders > 100 && withBrowser) throw new Error("File tree did not settle"); + const result = useFileTreeEntries(input); + useLayoutEffect(() => { + current = result; + }); + return withBrowser ? ( + + ) : null; + } + const unmount = () => { + act(() => root.unmount()); + container.remove(); + }; + return { + requests, + rootQuery, + searchQuery, + get searchSuccess() { + const result = searchQuery.success ?? state.results.get(searchQuery)?.result; + if (result?._tag !== "Success") throw new Error("Search has not succeeded"); + return result; + }, + get responseTimestamp() { + return state.responseTimestamp; + }, + controlSelection(path: string) { + controlledSelection = path; + }, + updateView(expanded: string[], selected: string) { + renders = 0; + act(() => { + current.setExpandedPaths(() => new Set(expanded)); + current.selectPath(selected); + }); + }, + unmount, + get current() { + return current; + }, + get rows() { + return [...container.querySelectorAll("button")].map((button) => + button.getAttribute("aria-label"), + ); + }, + press(label: string) { + renders = 0; + const button = container.querySelector(`button[aria-label="${label}"]`); + if (!button) throw new Error(`No row labelled ${label}`); + act(() => button.click()); + }, + render(target: Partial = {}) { + renders = 0; + const probe = ; + act(() => root.render(probe)); + }, + load(path = "src", refresh = false) { + renders = 0; + act(() => { + void current.loadDirectory(path, refresh); + }); + }, + async succeed(index: number, entries: ProjectEntry[] = []) { + renders = 0; + await act(async () => { + requests[index]!.resolve({ + _tag: "Success", + value: { entries, truncated: false }, + timestamp: ++state.responseTimestamp, + }); + }); + }, + async fail(index: number, message = "Directory unavailable") { + renders = 0; + await act(async () => { + requests[index]!.resolve({ _tag: "Failure", cause: Cause.fail(new Error(message)) }); + }); + }, + [Symbol.dispose]: unmount, + }; +} + +describe("file tree workspace state", () => { + it("retries failed folders once on reconnect", async () => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.fail(0); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1); + act(() => mocks.state.reconnect()); + expect(screen.requests).toHaveLength(2); + }); + + it("publishes immutable loading and loaded snapshots", async () => { + using screen = setup(); + screen.render(); + const initial = screen.current; + screen.load(); + const loading = screen.current; + expect(loading.isPending).toBe(true); + expect([...loading.loadingDirectories]).toEqual(["src"]); + expect([...initial.loadingDirectories]).toEqual([]); + await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + expect(screen.current.isPending).toBe(false); + expect([...screen.current.loadingDirectories]).toEqual([]); + expect(loading.entries.map((entry) => entry.path)).toEqual(["src"]); + expect([...loading.loadedDirectories]).toEqual([]); + expect([...loading.loadingDirectories]).toEqual(["src"]); + }); + + it.each([{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }])( + "isolates directory state and restores it on return from %j", + async (target) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "file", path: "src/first.ts" }]); + + screen.updateView(["src"], "src/first.ts"); + const firstView = screen.current; + screen.render(target); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect([...screen.current.loadingDirectories]).toEqual([]); + screen.load(); + expect(screen.requests[1]!.query).toEqual({ + environmentId: target.environmentId ?? firstTarget.environmentId, + input: { cwd: target.cwd ?? firstTarget.cwd, directoryPath: "src" }, + }); + await screen.succeed(1, [{ kind: "file", path: "src/second.ts" }]); + + screen.updateView([], "src/second.ts"); + expect([...firstView.expandedPaths]).toEqual(["src"]); + expect(firstView.selectedPath).toBe("src/first.ts"); + screen.render(); + expect([...screen.current.expandedPaths]).toEqual(["src"]); + expect(screen.current.selectedPath).toBe("src/first.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/first.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + screen.load(); + expect(screen.requests).toHaveLength(3); + await screen.succeed(2, [{ kind: "file", path: "src/first.ts" }]); + + screen.render(target); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBe("src/second.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/second.ts"]); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + }, + ); + + it("ignores stale successes and failures after leaving and returning to a workspace", async () => { + using screen = setup(); + screen.render(); + screen.load(); + screen.load("src/other"); + screen.render({ cwd: "/second" }); + screen.render(); + screen.load(); + + await screen.succeed(0, [{ kind: "file", path: "src/stale.ts" }]); + await screen.fail(1, "Stale error"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.error).toBeNull(); + expect([...screen.current.loadingDirectories]).toEqual(["src"]); + expect(screen.current.isPending).toBe(true); + + await screen.succeed(2, [{ kind: "file", path: "src/current.ts" }]); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/current.ts"]); + expect(screen.current.error).toBeNull(); + expect(screen.current.isPending).toBe(false); + expect([...screen.current.loadingDirectories]).toEqual([]); + }); + + it("retains recent listings and expansion when leaving a large workspace", async () => { + using screen = setup(); + screen.render(); + const paths = Array.from({ length: 70 }, (_, index) => `src/directory-${index}`); + screen.load(); + await screen.succeed( + 0, + paths.map((path) => ({ kind: "directory", path })), + ); + for (const path of paths) { + screen.load(path); + await screen.succeed(screen.requests.length - 1, [{ kind: "file", path: `${path}/file.ts` }]); + } + screen.updateView(["src", ...paths], `${paths[69]}/file.ts`); + screen.render({ cwd: "/other" }); + screen.render(); + expect(screen.current.loadedDirectories.size).toBe(64); + expect(screen.current.loadedDirectories.has(paths[69]!)).toBe(true); + expect(screen.current.expandedPaths.size).toBe(71); + expect(screen.current.selectedPath).toBe(`${paths[69]}/file.ts`); + for (let index = 1; index <= 8; index++) screen.render({ cwd: `/workspace-${index}` }); + screen.render(); + expect(screen.current.loadedDirectories.size).toBe(0); + expect(screen.current.expandedPaths.size).toBe(0); + expect(screen.current.selectedPath).toBeNull(); + }); + + it("retries a failed folder only when the user expands it again", async () => { + using screen = setup(true); + screen.render(); + expect(screen.rows).toEqual(["src"]); + expect(screen.requests).toHaveLength(0); + + screen.press("src"); + expect(screen.requests).toHaveLength(1); + await screen.fail(0); + expect(screen.current.error).toBe("Directory unavailable"); + // Re-rendering and the browser's passive expansion effect never retry a failed listing. + screen.render(); + screen.render(); + expect(screen.requests).toHaveLength(1); + expect(screen.rows).toEqual(["src"]); + + screen.press("src"); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.requests).toHaveLength(1); + screen.press("src"); + expect([...screen.current.expandedPaths]).toEqual(["src"]); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1, [{ kind: "file", path: "src/file.ts" }]); + expect(screen.current.error).toBeNull(); + expect(screen.rows).toEqual(["src", "src/file.ts"]); + + // A loaded folder serves from cache on collapse and expand. + screen.press("src"); + screen.press("src"); + expect(screen.rows).toEqual(["src", "src/file.ts"]); + expect(screen.requests).toHaveLength(2); + }); + + it("reveals a controlled leaf-only search result after search clears", () => { + using screen = setup(true); + screen.render(); + screen.searchQuery.data = { + entries: [{ kind: "file", path: "src/new/file.ts" }], + truncated: false, + }; + screen.controlSelection("src/new/file.ts"); + screen.render({ searchQuery: "file" }); + expect([...screen.current.expandedPaths]).toEqual(["src", "src/new"]); + screen.render(); + expect(screen.current.entries.map((entry) => entry.path)).toContain("src/new/file.ts"); + }); + + it.each(["collapse", "delete"])("settles controlled selection after %s", async (action) => { + using screen = setup(true); + screen.render(); + screen.updateView([], "src/nested/file.ts"); + await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]); + expect([...screen.current.expandedPaths]).toEqual(["src", "src/nested"]); + if (action === "collapse") { + screen.updateView([], "src/nested/file.ts"); + expect([...screen.current.expandedPaths]).toEqual([]); + screen.render({ cwd: "/second" }); + screen.render(); + expect([...screen.current.expandedPaths]).toEqual([]); + } + screen.rootQuery.data = { entries: [], truncated: false }; + screen.render(); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + if (action === "delete") { + screen.controlSelection("src/nested/file.ts"); + screen.searchQuery.data = { + entries: [ + { kind: "directory", path: "src" }, + { kind: "directory", path: "src/nested" }, + { kind: "file", path: "src/nested/file.ts" }, + ], + truncated: false, + }; + screen.rootQuery.failure = new ProjectListEntriesError({ + cwd: "/first", + failure: "workspace_root_not_found", + }); + screen.rootQuery.error = screen.rootQuery.failure.message; + const requestCount = screen.requests.length; + for (const error of [null, "Search unavailable"]) { + screen.searchQuery.error = error; + screen.render({ searchQuery: "nested" }); + expect(screen.current.entries).toEqual([]); + expect([...screen.current.expandedPaths]).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect(screen.current.isPending).toBe(false); + expect(screen.requests).toHaveLength(requestCount); + } + } + }); + + it.each(["return", "missing root"])( + "revalidates retained children after %s without clearing for transient errors", + async (scenario) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "file", path: "src/old.ts" }]); + screen.updateView(["src"], "src/old.ts"); + screen.render({ cwd: "/second" }); + screen.render(); + screen.rootQuery.error = "Connection unavailable"; + screen.render(); + expect(screen.current.selectedPath).toBe("src/old.ts"); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src", "src/old.ts"]); + if (scenario === "missing root") { + screen.rootQuery.failure = new ProjectListEntriesError({ + cwd: "/first", + failure: "workspace_root_not_found", + }); + screen.rootQuery.error = screen.rootQuery.failure.message; + expect(screen.rootQuery.error).toBe("Failed to list workspace entries in '/first'."); + screen.render(); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + } + screen.rootQuery.error = null; + screen.rootQuery.failure = null; + screen.render(); + if (scenario === "missing root") { + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.selectedPath).toBeNull(); + } + screen.load(); + expect(screen.requests).toHaveLength(2); + await screen.succeed(1, []); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + expect(screen.current.selectedPath).toBeNull(); + }, + ); + + it.each(["new", "src/new"])( + "keeps search discovery %s until its parent listing revalidates it", + async (path) => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, []); + screen.searchQuery.data = { + entries: [{ kind: "file", path: `${path}/file.ts` }], + truncated: false, + }; + screen.render({ searchQuery: "new" }); + const expanded = path.includes("/") ? ["src", path] : [path]; + screen.updateView(expanded, `${path}/file.ts`); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + screen.load(path); + await screen.succeed(1, [ + { kind: "file", path: `${path}/file.ts` }, + { kind: "file", path: `${path}/other.ts` }, + ]); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + + const cachedSearch = screen.searchSuccess; + for (const searchQuery of ["", "other"]) { + screen.searchQuery.data = { entries: [], truncated: false }; + screen.render({ searchQuery }); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` }); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` }); + } + + screen.render(); + for (const target of [{ cwd: "/second" }, { environmentId: EnvironmentId.make("second") }]) { + screen.render(target); + screen.render(); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/other.ts` }); + } + + screen.searchQuery.data = { + entries: [{ kind: "file", path: `${path}/file.ts` }], + truncated: false, + }; + screen.render({ searchQuery: "new" }); + for (const equalContents of [false, true]) { + // A later parent listing wins over retained discovery evidence. + if (path.includes("/")) { + // Refresh re-lists the root and every reachable folder; the empty parent prunes the child. + const refreshed = screen.requests.length; + act(() => { + void screen.current.refresh(); + }); + expect(screen.requests.slice(refreshed).map((request) => request.query.input)).toEqual([ + { cwd: "/first", directoryPath: "" }, + { cwd: "/first", query: "new", limit: 200 }, + { cwd: "/first", directoryPath: "src" }, + { cwd: "/first", directoryPath: path }, + ]); + // The parent settles last so its listing carries the newest response timestamp. + for (const index of [refreshed, refreshed + 1, refreshed + 3, refreshed + 2]) { + await screen.succeed(index, []); + } + } else { + const cachedData = screen.rootQuery.data; + screen.rootQuery.isPending = true; + screen.render({ searchQuery: "new" }); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + screen.rootQuery.isPending = false; + expect(screen.rootQuery.data).toBe(cachedData); + screen.render({ searchQuery: "new" }); + } + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + + // Cover an older cache and the timestamp's ambiguous same-millisecond boundary. + for (const success of [ + cachedSearch, + AsyncResult.success(cachedSearch.value, { timestamp: screen.responseTimestamp }), + ]) { + screen.searchQuery.data = { entries: [], truncated: false }; + screen.render({ searchQuery: "other" }); + screen.searchQuery.success = success; + screen.searchQuery.data = success.value; + screen.render({ searchQuery: "new" }); + screen.updateView(expanded, `${path}/file.ts`); + const requestCount = screen.requests.length; + screen.load(path); + expect(screen.requests).toHaveLength(requestCount + 1); + await screen.fail(requestCount); + screen.searchQuery.success = null; + screen.render(); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.expandedPaths]).toEqual(path.includes("/") ? ["src"] : []); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + expect( + screen.current.entries.some( + (entry) => entry.path === path || entry.path.startsWith(`${path}/`), + ), + ).toBe(false); + } + + const cachedSearchData = screen.searchQuery.data; + screen.searchQuery.isPending = true; + screen.render({ searchQuery: "new" }); + expect(screen.current.selectedPath).toBeNull(); + expect([...screen.current.loadedDirectories]).toEqual(["src"]); + screen.searchQuery.isPending = false; + if (!equalContents) { + screen.searchQuery.data = { + ...cachedSearchData, + entries: [...cachedSearchData.entries, { kind: "file", path: `${path}/added.ts` }], + }; + } + expect(screen.searchQuery.data === cachedSearchData).toBe(equalContents); + screen.render({ searchQuery: "new" }); + screen.updateView(expanded, `${path}/file.ts`); + screen.load(path); + await screen.succeed(screen.requests.length - 1, [ + { kind: "file", path: `${path}/file.ts` }, + { kind: "file", path: `${path}/recreated.ts` }, + ]); + expect(screen.current.selectedPath).toBe(`${path}/file.ts`); + expect([...screen.current.expandedPaths]).toEqual(expanded); + expect([...screen.current.loadedDirectories]).toEqual(["src", path]); + expect(screen.current.entries).toContainEqual({ kind: "file", path: `${path}/file.ts` }); + expect(screen.current.entries).toContainEqual({ + kind: "file", + path: `${path}/recreated.ts`, + }); + } + }, + ); + + it("prunes deleted directory contents, expansion and selection after a fresh parent listing", async () => { + using screen = setup(); + screen.render(); + screen.load(); + await screen.succeed(0, [{ kind: "directory", path: "src/nested" }]); + screen.load("src/nested"); + await screen.succeed(1, [{ kind: "file", path: "src/nested/file.ts" }]); + screen.updateView(["src", "src/nested"], "src/nested/file.ts"); + screen.rootQuery.data = { entries: [], truncated: true }; + screen.render(); + expect([...screen.current.loadedDirectories]).toEqual(["src", "src/nested"]); + expect(screen.current.selectedPath).toBe("src/nested/file.ts"); + screen.rootQuery.data = { entries: [], truncated: false }; + screen.render(); + expect(screen.current.entries).toEqual([]); + expect([...screen.current.loadedDirectories]).toEqual([]); + expect([...screen.current.expandedPaths]).toEqual([]); + expect(screen.current.selectedPath).toBeNull(); + screen.rootQuery.data = { entries: rootEntries, truncated: false }; + screen.render(); + expect(screen.current.entries.map((entry) => entry.path)).toEqual(["src"]); + }); +}); diff --git a/apps/mobile/src/features/files/useFileTreeEntries.ts b/apps/mobile/src/features/files/useFileTreeEntries.ts index d171f80e60287..1734cb705a75a 100644 --- a/apps/mobile/src/features/files/useFileTreeEntries.ts +++ b/apps/mobile/src/features/files/useFileTreeEntries.ts @@ -1,9 +1,9 @@ import { useAtomMount, useAtomSubscribe } from "@effect/atom-react"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; -import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; +import { ProjectListEntriesError, type EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { Atom } from "effect/reactivity"; -import { useEffect, useReducer, useRef, useState } from "react"; import { environmentCatalog } from "../../connection/catalog"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -11,6 +11,92 @@ import { projectEnvironment } from "../../state/projects"; import { useDebouncedValue } from "../../state/queries"; import { useEnvironmentQuery } from "../../state/query"; +function createDirectories() { + return { + target: {}, + entries: new Map>(), + errors: new Map(), + completeDirectories: new Set(), + listingCompletedAt: new Map(), + discoveries: new Map(), + rootListing: null as { readonly timestamp: number } | null, + validatedDirectories: new Set(), + loading: new Set(), + expandedPaths: new Set(), + selectedPath: null as string | null, + revealedSelection: { path: null as string | null, ancestors: new Set() }, + isRefreshing: false, + }; +} + +function boundDirectories(current: ReturnType, preserveVisible = true) { + const visible = (path: string) => { + const parts = path.split("/"); + return parts.every((_, index) => + current.expandedPaths.has(parts.slice(0, index + 1).join("/")), + ); + }; + const inactive = [...new Set([...current.entries.keys(), ...current.errors.keys()])].filter( + (path) => !preserveVisible || !visible(path), + ); + const ownedPaths = [ + ...current.entries.keys(), + ...current.expandedPaths, + current.selectedPath, + ].filter((path) => path !== null); + const discoveries = new Map( + [...current.discoveries].filter(([path]) => + ownedPaths.some((owned) => owned === path || owned.startsWith(`${path}/`)), + ), + ); + if (inactive.length <= 64) + return discoveries.size === current.discoveries.size ? current : { ...current, discoveries }; + const evicted = new Set(inactive.slice(0, -64)); + // Evict listing data, not the user's expansion and selection. A missing parent reloads on return. + return { + ...current, + discoveries, + entries: new Map([...current.entries].filter(([path]) => !evicted.has(path))), + errors: new Map([...current.errors].filter(([path]) => !evicted.has(path))), + completeDirectories: new Set( + [...current.completeDirectories].filter((path) => !evicted.has(path)), + ), + validatedDirectories: new Set( + [...current.validatedDirectories].filter((path) => !evicted.has(path)), + ), + listingCompletedAt: new Map( + [...current.listingCompletedAt].filter(([path]) => !evicted.has(path)), + ), + }; +} + +function contradictsListing(path: string, parent: string, entries: ReadonlyArray) { + if (parent !== "" && !path.startsWith(`${parent}/`)) return false; + const child = path.slice(parent === "" ? 0 : parent.length + 1).split("/")[0]; + const childPath = parent === "" ? child : `${parent}/${child}`; + const entry = entries.find((item) => item.path === childPath); + return !entry || (path !== childPath && entry.kind !== "directory"); +} + +function createRequests() { + return { + active: true, + refreshVersion: 0, + pending: new Map(), + }; +} + +function getRequests( + requestsByTarget: WeakMap>, + target: object, +) { + let requests = requestsByTarget.get(target); + if (!requests) { + requests = createRequests(); + requestsByTarget.set(target, requests); + } + return requests; +} const noSessionGeneration = Atom.make(0); export function useFileTreeEntries(input: { @@ -27,6 +113,7 @@ export function useFileTreeEntries(input: { ? projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }) : null, ); + const { refresh: refreshRoot } = root; const search = useEnvironmentQuery( searching && debouncedQuery.length > 0 && cwd !== null && environmentId !== null ? projectEnvironment.searchEntries({ @@ -35,42 +122,102 @@ export function useFileTreeEntries(input: { }) : null, ); - const [, render] = useReducer((value: number) => value + 1, 0); - const [entriesRevision, entriesChanged] = useReducer((value: number) => value + 1, 0); - const refreshVersion = useRef(0); - const directories = { - cwd, - environmentId, - entries: new Map>(), - requested: new Set(), - pending: new Map(), - errors: new Map(), - }; - const [refreshingDirectories, setRefreshingDirectories] = useState( - null, - ); - useEffect( - () => () => { - refreshVersion.current++; - for (const controller of directories.pending.values()) controller.abort(); - directories.pending.clear(); - }, - [directories], + const workspaceKey = JSON.stringify([environmentId, cwd]); + const [cache, setCache] = useState(() => ({ + key: workspaceKey, + workspaces: new Map([[workspaceKey, createDirectories()]]), + })); + const savedDirectories = cache.workspaces.get(workspaceKey) ?? createDirectories(); + // Requests belong to a visit, so returning cannot revive an old completion. + const directories = + cache.key === workspaceKey + ? savedDirectories + : { + ...savedDirectories, + target: {}, + validatedDirectories: new Set(), + errors: new Map(), + loading: new Set(), + isRefreshing: false, + }; + if (cache.key !== workspaceKey) { + const workspaces = new Map(cache.workspaces); + const previous = workspaces.get(cache.key); + if (previous) { + workspaces.set( + cache.key, + boundDirectories( + { + ...previous, + errors: new Map(), + loading: new Set(), + isRefreshing: false, + }, + false, + ), + ); + } + workspaces.delete(workspaceKey); + workspaces.set(workspaceKey, directories); + if (workspaces.size > 8) workspaces.delete(workspaces.keys().next().value!); + setCache({ key: workspaceKey, workspaces }); + } + const setDirectories = useCallback( + ( + update: ( + current: ReturnType, + ) => ReturnType, + ) => + setCache((current) => { + const previous = current.workspaces.get(workspaceKey); + if (!previous) return current; + const next = boundDirectories(update(previous)); + if (next === previous) return current; + return { ...current, workspaces: new Map(current.workspaces).set(workspaceKey, next) }; + }), + [workspaceKey], ); + const { target } = directories; + const requestsByTarget = useRef(new WeakMap>()); + // Cancel the old target before consumers' passive effects start new directory loads. + useLayoutEffect(() => { + const requests = getRequests(requestsByTarget.current, target); + requests.active = true; + refreshRoot(); + return () => { + requests.active = false; + requests.refreshVersion++; + for (const controller of requests.pending.values()) controller.abort(); + requests.pending.clear(); + setDirectories((current) => + current.target === target + ? { ...current, loading: new Set(), isRefreshing: false } + : current, + ); + }; + }, [target, setDirectories, requestsByTarget, refreshRoot]); + const updateDirectories = ( + update: (current: ReturnType) => ReturnType, + ) => setDirectories((current) => (current.target === target ? update(current) : current)); const loadDirectory = (directoryPath: string, refresh = false) => { + const requests = getRequests(requestsByTarget.current, target); if ( + !requests.active || + missingRoot || cwd === null || environmentId === null || - (!refresh && directories.entries.has(directoryPath)) || - directories.pending.has(directoryPath) + (!refresh && directories.validatedDirectories.has(directoryPath)) || + requests.pending.has(directoryPath) ) { return; } const controller = new AbortController(); - directories.requested.add(directoryPath); - directories.pending.set(directoryPath, controller); - directories.errors.delete(directoryPath); - render(); + requests.pending.set(directoryPath, controller); + updateDirectories((current) => { + const errors = new Map(current.errors); + errors.delete(directoryPath); + return { ...current, errors, loading: new Set(current.loading).add(directoryPath) }; + }); const atom = projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath } }); appAtomRegistry.refresh(atom); return executeAtomQuery(appAtomRegistry, atom, { @@ -79,26 +226,73 @@ export function useFileTreeEntries(input: { reportDefect: false, }).then((result) => { if (controller.signal.aborted) return; - directories.pending.delete(directoryPath); - if (result._tag === "Success") { - directories.entries.set( - directoryPath, - result.value.entries.filter( - (entry) => - entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, - ), - ); - entriesChanged(); - } else { + requests.pending.delete(directoryPath); + updateDirectories((current) => { + const validatedDirectories = new Set(current.validatedDirectories).add(directoryPath); + const loading = new Set(current.loading); + loading.delete(directoryPath); + if (result._tag === "Success") { + const entries = new Map(current.entries); + entries.delete(directoryPath); + entries.set( + directoryPath, + result.value.entries.filter( + (entry) => + entry.path.slice(0, Math.max(0, entry.path.lastIndexOf("/"))) === directoryPath, + ), + ); + const completeDirectories = new Set(current.completeDirectories); + if (result.value.truncated) completeDirectories.delete(directoryPath); + else completeDirectories.add(directoryPath); + return { + ...current, + entries, + loading, + completeDirectories, + discoveries: result.value.truncated + ? current.discoveries + : new Map( + [...current.discoveries].filter( + ([path]) => !contradictsListing(path, directoryPath, result.value.entries), + ), + ), + validatedDirectories, + listingCompletedAt: new Map(current.listingCompletedAt).set( + directoryPath, + result.timestamp, + ), + }; + } const error = Cause.squash(result.cause); - directories.errors.set( - directoryPath, - error instanceof Error ? error.message : "Files unavailable", - ); - } - render(); + return { + ...current, + loading, + validatedDirectories, + errors: new Map(current.errors).set( + directoryPath, + error instanceof Error ? error.message : "Files unavailable", + ), + }; + }); }); }; + const missingRoot = + root.failure instanceof ProjectListEntriesError && + root.failure.failure === "workspace_root_not_found"; + const rootData = missingRoot ? null : root.data; + const rootListing = root.successResult ?? directories.rootListing; + if ( + missingRoot && + (directories.entries.size > 0 || + directories.errors.size > 0 || + directories.loading.size > 0 || + directories.expandedPaths.size > 0 || + directories.selectedPath !== null || + directories.discoveries.size > 0) + ) { + setDirectories(() => createDirectories()); + } + const { refresh: refreshSearch, data: searchData, successResult: searchResult } = search; // A folder read fails at once while its environment is unreachable; read it again once a new // session is up rather than leaving the error until someone refreshes. const sessionGeneration = @@ -108,13 +302,11 @@ export function useFileTreeEntries(input: { // A listener alone does not compute the atom, so nothing would follow the connection. useAtomMount(sessionGeneration); useAtomSubscribe(sessionGeneration, () => { - for (const path of directories.errors.keys()) void loadDirectory(path); + for (const path of directories.errors.keys()) void loadDirectory(path, true); }); - const { refresh: refreshRoot, data: rootData } = root; - const { refresh: refreshSearch, data: searchData } = search; const snapshot = (() => { - const merged = new Map(); - if (searching) { + const merged = new Map(missingRoot ? [] : directories.discoveries); + if (searching && !missingRoot) { for (const entry of searchData?.entries ?? []) merged.set(entry.path, entry); } const reachableDirectories = new Set(); @@ -127,31 +319,158 @@ export function useFileTreeEntries(input: { } } }; + if (!missingRoot) { + const discoveries = new Map(directories.discoveries); + // Search can return only leaves; their inferred parents still own loaded children. + for (const entry of directories.discoveries.values()) { + const parts = entry.path.split("/"); + for (let index = 1; index < parts.length; index++) { + const path = parts.slice(0, index).join("/"); + if (!discoveries.has(path)) discoveries.set(path, { kind: "directory", path }); + } + } + visit([...discoveries.values()]); + } visit((rootData?.entries ?? []).filter((entry) => !entry.path.includes("/"))); return { - revision: entriesRevision, entries: [...merged.values()], reachableDirectories, - searchResultPaths: searching - ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) - : null, + searchResultPaths: + searching && !missingRoot + ? new Set((searchData?.entries ?? []).map((entry) => entry.path)) + : null, }; })(); + if (rootData) { + const deleted = (path: string, directory = false) => { + const parts = path.split("/"); + for (let index = 0; index < parts.length; index++) { + const parent = parts.slice(0, index).join("/"); + const listing = index === 0 ? rootData.entries : directories.entries.get(parent); + const child = parts.slice(0, index + 1).join("/"); + const complete = + index === 0 ? !rootData.truncated : directories.completeDirectories.has(parent); + const entry = listing?.find((item) => item.path === child); + if (complete && listing && !entry) { + const listingCompletedAt = + index === 0 && directories.rootListing !== rootListing + ? rootListing?.timestamp + : directories.listingCompletedAt.get(parent); + // Cached queries keep their completion time across query switches. + // Equal-millisecond responses cannot prove a path postdates the listing. + const retained = + !(index === 0 && directories.rootListing !== rootListing) && + [...directories.discoveries.keys()].some( + (path) => path === child || path.startsWith(`${child}/`), + ); + const discovered = + retained || + (searching && + query === debouncedQuery && + searchResult !== null && + searchResult.timestamp > (listingCompletedAt ?? -Infinity) && + searchData?.entries.some( + (item) => item.path === child || item.path.startsWith(`${child}/`), + )); + if (!discovered) return true; + } + if (entry && (directory || index < parts.length - 1) && entry.kind !== "directory") + return true; + } + return false; + }; + const reconciled = (() => { + const current = directories; + const discoveries = new Map(current.discoveries); + if (current.rootListing !== rootListing && !rootData.truncated) { + for (const path of discoveries.keys()) { + if (contradictsListing(path, "", rootData.entries)) discoveries.delete(path); + } + } + if (searching && query === debouncedQuery && searchResult !== null) { + const ownedPaths = [ + ...current.entries.keys(), + ...current.expandedPaths, + current.selectedPath, + ]; + for (const entry of searchData?.entries ?? []) { + if ( + ownedPaths.some((path) => path === entry.path || path?.startsWith(`${entry.path}/`)) && + !deleted(entry.path, entry.kind === "directory") + ) + discoveries.set(entry.path, entry); + } + } + const entries = new Map([...current.entries].filter(([path]) => !deleted(path, true))); + const errors = new Map([...current.errors].filter(([path]) => !deleted(path, true))); + const expandedPaths = new Set( + [...current.expandedPaths].filter((path) => !deleted(path, true)), + ); + const selectedPath = + current.selectedPath !== null && deleted(current.selectedPath) + ? null + : current.selectedPath; + if ( + entries.size === current.entries.size && + errors.size === current.errors.size && + expandedPaths.size === current.expandedPaths.size && + selectedPath === current.selectedPath && + current.rootListing === rootListing && + discoveries.size === current.discoveries.size && + [...discoveries].every(([path, entry]) => current.discoveries.get(path) === entry) + ) + return current; + const completeDirectories = new Set( + [...current.completeDirectories].filter((path) => entries.has(path)), + ); + return { + ...current, + rootListing, + discoveries, + listingCompletedAt: new Map( + [...current.listingCompletedAt].filter(([path]) => path === "" || entries.has(path)), + ).set( + "", + current.rootListing === rootListing + ? (current.listingCompletedAt.get("") ?? -Infinity) + : (rootListing?.timestamp ?? -Infinity), + ), + entries, + errors, + expandedPaths, + selectedPath, + completeDirectories, + validatedDirectories: new Set( + [...current.validatedDirectories].filter((path) => !deleted(path, true)), + ), + }; + })(); + if (reconciled !== directories) setDirectories(() => reconciled); + } + const refresh = () => { + const requests = getRequests(requestsByTarget.current, target); + if (!requests.active) return Promise.resolve(); refreshRoot(); - if (searching) refreshSearch(); + if (searching && !missingRoot) refreshSearch(); const paths = new Set( - [...directories.requested].filter((path) => snapshot.reachableDirectories.has(path)), + [...directories.entries.keys(), ...directories.errors.keys(), ...directories.loading].filter( + (path) => snapshot.reachableDirectories.has(path), + ), ); - for (const controller of directories.pending.values()) controller.abort(); - directories.pending.clear(); - directories.errors.clear(); - const version = ++refreshVersion.current; - setRefreshingDirectories(directories); + for (const controller of requests.pending.values()) controller.abort(); + requests.pending.clear(); + const version = ++requests.refreshVersion; + updateDirectories((current) => ({ + ...current, + errors: new Map(), + loading: new Set(), + isRefreshing: true, + })); const remaining = paths.values(); const worker = async () => { - while (version === refreshVersion.current) { + while (requests.active && version === requests.refreshVersion) { const next = remaining.next(); if (next.done) return; await loadDirectory(next.value, true); @@ -161,7 +480,7 @@ export function useFileTreeEntries(input: { cwd !== null && environmentId !== null ? [ projectEnvironment.listEntries({ environmentId, input: { cwd, directoryPath: "" } }), - ...(searching + ...(searching && !missingRoot ? [ projectEnvironment.searchEntries({ environmentId, @@ -177,29 +496,83 @@ export function useFileTreeEntries(input: { ), ...Array.from({ length: Math.min(4, paths.size) }, () => worker()), ]; - render(); return Promise.all(work).finally(() => { - if (version === refreshVersion.current) setRefreshingDirectories(null); + if (version === requests.refreshVersion) { + updateDirectories((current) => ({ ...current, isRefreshing: false })); + } }); }; + const revealSelection = (path: string | null) => { + if (missingRoot) return; + updateDirectories((current) => { + const previous = current.revealedSelection; + const handled = previous.path === path ? previous.ancestors : new Set(); + const parts = path?.split("/").filter(Boolean) ?? []; + const ancestors = parts + .slice(1) + .map((_, index) => parts.slice(0, index + 1).join("/")) + .filter( + (ancestor) => + !handled.has(ancestor) && + snapshot.entries.some( + (entry) => + (entry.kind === "directory" && entry.path === ancestor) || + (entry.path === path && entry.path.startsWith(`${ancestor}/`)), + ), + ); + if (previous.path === path && ancestors.length === 0) return current; + return { + ...current, + discoveries: new Map([ + ...current.discoveries, + ...snapshot.entries + .filter((entry) => entry.path === path) + .map((entry) => [entry.path, entry] as const), + ]), + selectedPath: path, + revealedSelection: { path, ancestors: new Set([...handled, ...ancestors]) }, + expandedPaths: new Set([...current.expandedPaths, ...ancestors]), + }; + }); + }; + + const setExpandedPaths = useCallback( + (update: (current: ReadonlySet) => ReadonlySet) => + setDirectories((current) => { + const expandedPaths = update(current.expandedPaths); + return expandedPaths === current.expandedPaths + ? current + : { ...current, expandedPaths: new Set(expandedPaths) }; + }), + [setDirectories], + ); + return { + revealSelection, + expandedPaths: directories.expandedPaths, + setExpandedPaths, + selectedPath: directories.selectedPath, + selectPath: (selectedPath: string) => + updateDirectories((current) => ({ ...current, selectedPath })), entries: snapshot.entries, searchResultPaths: snapshot.searchResultPaths, - isRefreshing: refreshingDirectories === directories, + isRefreshing: directories.isRefreshing, error: root.error ?? (searching ? search.error : null) ?? [...directories.errors].find(([path]) => snapshot.reachableDirectories.has(path))?.[1] ?? null, isPending: - root.isPending || - directories.pending.size > 0 || - (searching && (query !== debouncedQuery || search.isPending)), - searchTruncated: searching && (search.data?.truncated ?? false), + !missingRoot && + (root.isPending || + directories.loading.size > 0 || + (searching && (query !== debouncedQuery || search.isPending))), + searchTruncated: !missingRoot && searching && (search.data?.truncated ?? false), loadedDirectories: new Set(directories.entries.keys()), - loadingDirectories: new Set(directories.pending.keys()), - loadDirectory, + loadingDirectories: directories.loading, + loadDirectory: (path: string, retry = false) => + loadDirectory(path, retry && directories.errors.has(path)), refresh, }; } diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index ad8a0f2cdf979..b5d5b6a6a1a66 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -9,7 +9,9 @@ const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(fals export interface EnvironmentQueryView { readonly data: A | null; + readonly successResult: AsyncResult.Success | null; readonly error: string | null; + readonly failure: unknown; readonly isPending: boolean; readonly isSuccess: boolean; readonly refresh: () => void; @@ -30,6 +32,9 @@ export function useEnvironmentQuery( const refresh = useAtomRefresh(selectedAtom); return { data: Option.getOrNull(AsyncResult.value(result)), + // Data may be structurally shared; only a settled success proves a new response. + successResult: result._tag === "Success" && !result.waiting ? result : null, + failure: result._tag === "Failure" ? Cause.squash(result.cause) : null, error: result._tag === "Failure" ? formatError(result.cause) : null, isPending: atom !== null && result.waiting, isSuccess: result._tag === "Success", diff --git a/apps/mobile/vite.config.ts b/apps/mobile/vite.config.ts index 8eaaaa1604fbb..e1509e98e1135 100644 --- a/apps/mobile/vite.config.ts +++ b/apps/mobile/vite.config.ts @@ -4,6 +4,7 @@ import { defineConfig, normalizePath } from "vite-plus"; import "vite-plus/test/config"; const reactTests = [ + "src/features/files/useFileTreeEntries.test.tsx", "src/features/projects/AddProjectScreen.test.ts", "src/lib/useMediaVideoPlayback.test.tsx", "src/features/pull-requests/usePullRequestEnvironmentFallback.test.tsx", From 83838af7ba7dcc539311640d09b6338372beb148 Mon Sep 17 00:00:00 2001 From: Alex Southwell Date: Tue, 27 Oct 2026 05:13:14 +0000 Subject: [PATCH 002/317] fix(mobile): finished subagents stay reachable from the Agents pill (#25759) Co-authored-by: Claude Opus 5.5 --- .../src/state/threadSubagents.test.ts | 96 ++++++++++++++++++- .../src/state/threadSubagents.ts | 30 +++++- 2 files changed, 122 insertions(+), 4 deletions(-) diff --git a/packages/client-runtime/src/state/threadSubagents.test.ts b/packages/client-runtime/src/state/threadSubagents.test.ts index e5dae9f50a368..26b9a785c02d3 100644 --- a/packages/client-runtime/src/state/threadSubagents.test.ts +++ b/packages/client-runtime/src/state/threadSubagents.test.ts @@ -41,8 +41,17 @@ function subagent( }; } -const run = (id: string, status: "running" | "completed" | "rolled_back") => - ({ id: RunId.make(id), status }) as never; +const run = ( + id: string, + status: "queued" | "running" | "completed" | "cancelled" | "rolled_back", + overrides: { readonly ordinal?: number; readonly started?: boolean } = {}, +) => + ({ + id: RunId.make(id), + status, + ordinal: overrides.ordinal ?? Number(id.slice("run-".length)), + startedAt: overrides.started === false ? null : at("2026-06-20T00:00:00.000Z"), + }) as never; const activeRun = run("run-1", "running"); const finishedRun = run("run-1", "completed"); @@ -168,6 +177,89 @@ describe("deriveThreadTurnSubagents", () => { expect(resolveSubagentPillSegment(turn)?.label).toBe("1/2"); }); + it("keeps the finished agents reachable while their turn is the latest", () => { + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun], + subagents: [ + subagent({ id: "a", status: "completed" }), + subagent({ id: "b", status: "failed" }), + ], + }); + + expect(resolveSubagentPillSegment(turn)?.label).toBe("2 done"); + }); + + it("drops finished agents from the pill once a later turn ran without any", () => { + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun, run("run-2", "completed")], + subagents: [subagent({ id: "a", status: "completed" })], + }); + + expect(resolveSubagentPillSegment(turn)).toBeNull(); + }); + + it("shows finished agents again when the later turn was rolled back", () => { + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun, run("run-2", "rolled_back")], + subagents: [subagent({ id: "a", status: "completed" })], + }); + + expect(resolveSubagentPillSegment(turn)?.label).toBe("1 done"); + }); + + it("keeps finished agents while a later turn is only queued or was cancelled before starting", () => { + for (const later of [ + run("run-2", "queued", { started: false }), + run("run-2", "cancelled", { started: false }), + ]) { + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun, later], + subagents: [subagent({ id: "a", status: "completed" })], + }); + + expect(resolveSubagentPillSegment(turn)?.label).toBe("1 done"); + } + }); + + it("finds the latest turn by ordinal when a reordered queue left runs out of order", () => { + const turn = deriveThreadTurnSubagents({ + runs: [run("run-1", "completed", { ordinal: 3 }), run("run-2", "completed", { ordinal: 2 })], + subagents: [subagent({ id: "a", status: "completed" })], + }); + + expect(resolveSubagentPillSegment(turn)?.label).toBe("1 done"); + }); + + it("keeps the latest turn's agents when an earlier turn's background agent finishes later", () => { + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun, run("run-2", "completed")], + subagents: [ + subagent({ id: "late", status: "completed", updatedAt: at("2026-06-20T00:00:09.000Z") }), + subagent({ + id: "latest", + runId: RunId.make("run-2"), + status: "completed", + updatedAt: at("2026-06-20T00:00:05.000Z"), + }), + ], + }); + + expect(turn?.subagents.map((entry) => entry.id)).toEqual(["latest"]); + expect(resolveSubagentPillSegment(turn)?.label).toBe("1 done"); + }); + + it("counts only terminal agents as done while a held agent remains", () => { + const turn = deriveThreadTurnSubagents({ + runs: [finishedRun], + subagents: [ + subagent({ id: "a", status: "completed" }), + subagent({ id: "b", status: "idle" }), + ], + }); + + expect(resolveSubagentPillSegment(turn)?.label).toBe("1 done"); + }); + it("counts idle as neither working nor done", () => { const turn = deriveThreadTurnSubagents({ runs: [activeRun], diff --git a/packages/client-runtime/src/state/threadSubagents.ts b/packages/client-runtime/src/state/threadSubagents.ts index c0c742cc18def..bff4d40fd420b 100644 --- a/packages/client-runtime/src/state/threadSubagents.ts +++ b/packages/client-runtime/src/state/threadSubagents.ts @@ -12,6 +12,8 @@ type RunId = Projection["runs"][number]["id"]; export interface ThreadTurnSubagents { readonly runId: RunId | null; readonly turnActive: boolean; + /** No later turn has run since these agents' turn. */ + readonly latestTurn: boolean; readonly subagents: ReadonlyArray; readonly liveCount: number; readonly settledCount: number; @@ -45,7 +47,22 @@ export function deriveThreadTurnSubagents( : latest, first, ); - const runId = activeRun?.id ?? latestUpdated.runId; + // Ordinal, not array position: a reordered queue can leave the projection's runs out of order. + // Queued and never-started runs have not replaced anything yet. + const latestStartedRun = projection.runs.reduce( + (latest, run) => + run.startedAt !== null && + !rolledBackRunIds.has(run.id) && + (latest === null || run.ordinal > latest.ordinal) + ? run + : latest, + null, + ); + const latestStartedRunHasAgents = + latestStartedRun !== null && + eligible.some((subagent) => subagent.runId === latestStartedRun.id); + const runId = + activeRun?.id ?? (latestStartedRunHasAgents ? latestStartedRun.id : latestUpdated.runId); const subagents = copySorted( eligible.filter( (subagent) => @@ -63,15 +80,24 @@ export function deriveThreadTurnSubagents( if (isActiveSubagentStatus(subagent.status)) liveCount += 1; else if (isTerminalSubagentStatus(subagent.status)) settledCount += 1; } - return { runId, turnActive: activeRun !== null, subagents, liveCount, settledCount }; + return { + runId, + turnActive: activeRun !== null, + latestTurn: runId === null || latestStartedRun === null || runId === latestStartedRun.id, + subagents, + liveCount, + settledCount, + }; } export function resolveSubagentPillSegment( turn: ThreadTurnSubagents | null, ): SubagentPillSegment | null { if (turn === null) return null; + // Finished agents stay reachable until a later turn replaces them; workflows always stay reachable. if ( !turn.turnActive && + !turn.latestTurn && turn.liveCount === 0 && !turn.subagents.some((subagent) => subagent.workflow !== undefined) ) { From e689dad42c842d03f311ee0a7c29db25896abd6a Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 26 Oct 2026 22:16:17 -0700 Subject: [PATCH 003/317] fix(mobile): keep segmented controls readable on cards and screens (#25729) --- .../components/ui/SegmentedControl.ios.tsx | 32 +++---- .../components/ui/SegmentedControl.shared.tsx | 84 +++++++++++++++++++ .../src/components/ui/SegmentedControl.tsx | 79 +---------------- .../components/ui/SegmentedControl.types.ts | 1 + .../components/ui/segmentedControlColors.ts | 58 +++++++++++++ .../src/components/ui/useControlTheme.ts | 3 +- .../development/ComponentCatalogScreen.tsx | 10 +++ .../SettingsScheduledTasksRouteScreen.tsx | 1 + .../threads/CustomSnoozeSheet.android.tsx | 9 +- apps/mobile/src/lib/mobileTheme.ts | 6 ++ 10 files changed, 183 insertions(+), 100 deletions(-) create mode 100644 apps/mobile/src/components/ui/SegmentedControl.shared.tsx create mode 100644 apps/mobile/src/components/ui/segmentedControlColors.ts diff --git a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx index 4862e99cdabfa..15883d300da41 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.ios.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.ios.tsx @@ -1,11 +1,8 @@ import { requireNativeView } from "expo"; import { View, type NativeSyntheticEvent, type ViewProps } from "react-native"; -import { - flattenThemeColor, - themeColorWithAlpha, - themeColorWithContrast, -} from "../../lib/mobileTheme"; +import { SegmentedControl as FlatSegmentedControl } from "./SegmentedControl.shared"; +import { segmentedControlColors } from "./segmentedControlColors"; import { useControlTheme } from "./useControlTheme"; import type { SegmentedControlProps } from "./SegmentedControl.types"; @@ -27,17 +24,16 @@ const NativeSegmentedControl = requireNativeView< export function SegmentedControl( props: SegmentedControlProps, ) { - const { colors, colorScheme } = useControlTheme(); - const trackColor = flattenThemeColor( - themeColorWithAlpha(colors["--color-prominent-action"], colorScheme === "dark" ? 0.18 : 0.07), - colors["--color-screen"], + const { colors, colorScheme, themeId } = useControlTheme(); + // UIKit adds a gray fill over the sunken card track. + if (props.variant === "card") return ; + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + "ios", + themeId, ); - const selectedColor = - colorScheme === "dark" ? colors["--color-prominent-action"] : colors["--color-secondary"]; - const selectedTextColor = - colorScheme === "dark" - ? colors["--color-prominent-action-foreground"] - : colors["--color-secondary-foreground"]; return ( ( trackColor={trackColor} selectedColor={selectedColor} textColor={selectedTextColor} - mutedColor={themeColorWithContrast( - colors["--color-foreground-muted"], - trackColor, - colorScheme === "dark" ? 7 : 4.5, - )} + mutedColor={mutedColor} appearance={colorScheme} onSelectionChange={({ nativeEvent }) => { const option = props.options[nativeEvent.index]; diff --git a/apps/mobile/src/components/ui/SegmentedControl.shared.tsx b/apps/mobile/src/components/ui/SegmentedControl.shared.tsx new file mode 100644 index 0000000000000..9eac685a8ca21 --- /dev/null +++ b/apps/mobile/src/components/ui/SegmentedControl.shared.tsx @@ -0,0 +1,84 @@ +import { useState } from "react"; +import { I18nManager, Platform, Pressable, View } from "react-native"; +import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; + +import { segmentedControlColors } from "./segmentedControlColors"; +import { AppText } from "./AppText"; +import { useControlTheme } from "./useControlTheme"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; + +export type { SegmentedControlProps } from "./SegmentedControl.types"; + +export function SegmentedControl( + props: SegmentedControlProps, +) { + const { colorScheme, colors, themeId } = useControlTheme(); + const [layout, setLayout] = useState({ width: 0, height: 0 }); + const { width } = layout; + const reduceMotion = useReducedMotion(); + const selectedIndex = props.options.findIndex((option) => option.value === props.selected); + const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; + const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); + const selectionStyle = useAnimatedStyle(() => ({ + width: segmentWidth, + transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], + })); + const { trackColor, selectedColor, selectedTextColor, mutedColor } = segmentedControlColors( + colors, + colorScheme, + props.variant, + Platform.OS, + themeId, + ); + const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; + + return ( + + setLayout(event.nativeEvent.layout)} + className="flex-row rounded-full p-[3px]" + style={{ + minHeight, + backgroundColor: trackColor, + }} + > + {selectedIndex >= 0 && width > 0 ? ( + + ) : null} + {props.options.map((option) => { + const selected = option.value === props.selected; + return ( + props.onSelect(option.value)} + className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" + style={{ minHeight: minHeight - 6 }} + > + + {option.label} + + + ); + })} + + + ); +} diff --git a/apps/mobile/src/components/ui/SegmentedControl.tsx b/apps/mobile/src/components/ui/SegmentedControl.tsx index 0fdc46c0f27ef..bbcbc4a5f4593 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.tsx +++ b/apps/mobile/src/components/ui/SegmentedControl.tsx @@ -1,78 +1 @@ -import { useState } from "react"; -import { I18nManager, Platform, Pressable, View } from "react-native"; -import Animated, { useAnimatedStyle, useReducedMotion, withTiming } from "react-native-reanimated"; - -import { AppText } from "./AppText"; -import { useControlTheme } from "./useControlTheme"; -import type { SegmentedControlProps } from "./SegmentedControl.types"; - -export type { SegmentedControlProps } from "./SegmentedControl.types"; - -export function SegmentedControl( - props: SegmentedControlProps, -) { - const { colorScheme, colors } = useControlTheme(); - const [layout, setLayout] = useState({ width: 0, height: 0 }); - const { width } = layout; - const reduceMotion = useReducedMotion(); - const selectedIndex = props.options.findIndex((option) => option.value === props.selected); - const segmentWidth = props.options.length ? Math.max(0, width - 6) / props.options.length : 0; - const selectionOffset = selectedIndex * segmentWidth * (I18nManager.isRTL ? -1 : 1); - const selectionStyle = useAnimatedStyle(() => ({ - width: segmentWidth, - transform: [{ translateX: withTiming(selectionOffset, { duration: reduceMotion ? 0 : 160 }) }], - })); - const dark = colorScheme === "dark"; - const minHeight = props.size === "compact" ? 32 : Platform.OS === "android" ? 40 : 36; - - return ( - - setLayout(event.nativeEvent.layout)} - className="flex-row rounded-full p-[3px]" - style={{ - minHeight, - backgroundColor: colors[dark ? "--color-screen" : "--color-grouped-card"], - }} - > - {selectedIndex >= 0 && width > 0 ? ( - - ) : null} - {props.options.map((option) => { - const selected = option.value === props.selected; - return ( - props.onSelect(option.value)} - className="flex-1 items-center justify-center rounded-full px-2 py-1 active:opacity-70" - style={{ minHeight: minHeight - 6 }} - > - - {option.label} - - - ); - })} - - - ); -} +export { SegmentedControl, type SegmentedControlProps } from "./SegmentedControl.shared"; diff --git a/apps/mobile/src/components/ui/SegmentedControl.types.ts b/apps/mobile/src/components/ui/SegmentedControl.types.ts index 779b49fe1a4b4..472ed46b0e2f2 100644 --- a/apps/mobile/src/components/ui/SegmentedControl.types.ts +++ b/apps/mobile/src/components/ui/SegmentedControl.types.ts @@ -8,6 +8,7 @@ export interface SegmentedControlProps { readonly onSelect: (value: Value) => void; /** Compact uses a 32-point capsule; default uses 36 points on iOS and 40 on Android. */ readonly size?: "default" | "compact"; + readonly variant?: "standalone" | "card"; readonly role?: "tab" | "button"; readonly className?: string; } diff --git a/apps/mobile/src/components/ui/segmentedControlColors.ts b/apps/mobile/src/components/ui/segmentedControlColors.ts new file mode 100644 index 0000000000000..6b58675726e4f --- /dev/null +++ b/apps/mobile/src/components/ui/segmentedControlColors.ts @@ -0,0 +1,58 @@ +import { + flattenThemeColor, + themeColorContrastRatio, + themeColorWithAlpha, + themeColorWithContrast, + type MobileThemeAppearance, + type MobileThemeId, + type MobileThemeVariables, +} from "../../lib/mobileTheme"; +import type { SegmentedControlProps } from "./SegmentedControl.types"; + +export function segmentedControlColors( + colors: MobileThemeVariables, + appearance: MobileThemeAppearance, + variant: SegmentedControlProps["variant"], + platform: string, + themeId: MobileThemeId, +) { + const dark = appearance === "dark"; + const card = variant === "card"; + const accent = + colors[themeId === "material-you" ? "--color-primary" : "--color-prominent-action"]; + const trackColor = card + ? dark + ? colors["--color-screen"] + : flattenThemeColor( + themeColorWithAlpha(colors["--color-foreground"], 0.07), + colors["--color-grouped-card"], + ) + : flattenThemeColor(themeColorWithAlpha(accent, dark ? 0.1 : 0.07), colors["--color-screen"]); + let selectedColor = colors["--color-secondary"]; + if (card && !dark) selectedColor = colors["--color-screen"]; + if (dark && (!card || themeId === "t3-code")) { + selectedColor = flattenThemeColor(themeColorWithAlpha(accent, 0.32), colors["--color-screen"]); + } + let selectedTextColor = + colors[!card && dark ? "--color-foreground" : "--color-secondary-foreground"]; + if (!card && !dark && platform === "android") { + const selection = colors["--color-thread-selected"]; + if ( + themeColorContrastRatio(selection, trackColor) > + themeColorContrastRatio(selectedColor, trackColor) + ) { + selectedColor = selection; + selectedTextColor = colors["--color-thread-selected-foreground"]; + } + } + return { + trackColor, + selectedColor, + selectedTextColor: themeColorWithContrast(selectedTextColor, selectedColor), + mutedColor: themeColorWithContrast( + colors["--color-foreground-muted"], + trackColor, + dark && !card ? 7 : 4.5, + ), + }; +} diff --git a/apps/mobile/src/components/ui/useControlTheme.ts b/apps/mobile/src/components/ui/useControlTheme.ts index d2181ba8954f8..cb7225eddb4db 100644 --- a/apps/mobile/src/components/ui/useControlTheme.ts +++ b/apps/mobile/src/components/ui/useControlTheme.ts @@ -1,9 +1,10 @@ import { useAppearancePreferences } from "../../features/settings/appearance/AppearancePreferencesProvider"; export function useControlTheme() { - const { themeAppearance, themeVariables, systemColorsActive, appearance } = + const { themeId, themeAppearance, themeVariables, systemColorsActive, appearance } = useAppearancePreferences(); return { + themeId, colorScheme: themeAppearance, colors: themeVariables, systemColorsActive, diff --git a/apps/mobile/src/features/development/ComponentCatalogScreen.tsx b/apps/mobile/src/features/development/ComponentCatalogScreen.tsx index 9a3d894d384f5..d692e3e129b0a 100644 --- a/apps/mobile/src/features/development/ComponentCatalogScreen.tsx +++ b/apps/mobile/src/features/development/ComponentCatalogScreen.tsx @@ -104,6 +104,16 @@ export function ComponentCatalogScreen() { Lists and selection + + + setSheet(true)} /> Custom snooze { @@ -155,6 +156,7 @@ export function CustomSnoozeSheet(props: Props) { {mode === "date" ? ( - + )} diff --git a/apps/mobile/src/lib/mobileTheme.ts b/apps/mobile/src/lib/mobileTheme.ts index ed45f0518845b..afe26b7ddd2ea 100644 --- a/apps/mobile/src/lib/mobileTheme.ts +++ b/apps/mobile/src/lib/mobileTheme.ts @@ -209,6 +209,12 @@ function contrastRatio( ); } +export function themeColorContrastRatio(first: string, second: string): number { + const firstChannels = rgbChannels(first); + const secondChannels = rgbChannels(second); + return firstChannels && secondChannels ? contrastRatio(firstChannels, secondChannels) : 1; +} + export function themeColorWithContrast( accent: string, surface: string | ReadonlyArray, From b1619f3a74cb228356a3ea3f58573f88da270817 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 22:18:11 -0700 Subject: [PATCH 004/317] fix(web): cancelling update everything no longer flashes the update button's focus ring and tooltip (#25774) Co-authored-by: Claude Opus 5.5 --- .../SidebarUpdatePill.browser.test.tsx | 41 +++++++++++++++++-- .../sidebar/UpdateEverythingDialog.tsx | 4 +- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx index edbe293b97998..c7b1ca448775c 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.browser.test.tsx @@ -1,7 +1,8 @@ import type { DesktopUpdateState } from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { userEvent } from "vite-plus/test/browser"; +import { page, userEvent } from "vite-plus/test/browser"; +import "~/index.css"; import { act, render } from "../../../test/render"; import { SidebarUpdatePill } from "./SidebarUpdatePill"; @@ -9,6 +10,7 @@ const mocks = vi.hoisted(() => { const listeners = new Set<() => void>(); return { update: null as DesktopUpdateState | null, + hasUpdates: false, subscribe: (listener: () => void) => { listeners.add(listener); return () => listeners.delete(listener); @@ -32,7 +34,13 @@ vi.mock("../../env", async (importOriginal) => ({ isElectron: true, })); vi.mock("./useUpdateEverything", () => ({ - useUpdateEverything: () => ({ hasUpdates: false, summary: "", lines: [], restarts: [] }), + useUpdateEverything: () => ({ + hasUpdates: mocks.hasUpdates, + summary: "Update everything", + lines: [], + restarts: [], + skipped: [], + }), })); function nightlyUpdate(releaseNotes: DesktopUpdateState["releaseNotes"]): DesktopUpdateState { @@ -60,8 +68,9 @@ const RELEASE_NOTES: DesktopUpdateState["releaseNotes"] = [ { version: "0.0.50-nightly.2", items: ["Faster sidebar."], totalItems: 1 }, ]; -function setup(update: DesktopUpdateState | null) { +function setup(update: DesktopUpdateState | null, { hasUpdates = false } = {}) { mocks.update = update; + mocks.hasUpdates = hasUpdates; } function publish(update: DesktopUpdateState | null) { @@ -94,3 +103,29 @@ describe("SidebarUpdatePill release notes", () => { await vi.waitFor(() => expect(releaseNotes()).not.toBeNull()); }); }); + +describe("SidebarUpdatePill update everything", () => { + it("does not refocus the update button after a pointer cancel", async () => { + setup(null, { hasUpdates: true }); + await using screen = await render(); + const button = screen.getByRole("button", { name: "Update everything" }); + await userEvent.click(button); + const cancel = page.getByRole("button", { name: "Cancel" }); + await expect.element(cancel).toBeVisible(); + + await userEvent.click(cancel); + await vi.waitFor(() => expect(document.querySelector('[role="alertdialog"]')).toBeNull()); + expect(document.activeElement).not.toBe(button.element()); + }); + + it("returns focus to the update button after Escape", async () => { + setup(null, { hasUpdates: true }); + await using screen = await render(); + const button = screen.getByRole("button", { name: "Update everything" }); + await userEvent.click(button); + await expect.element(page.getByRole("button", { name: "Cancel" })).toBeVisible(); + + await userEvent.keyboard("{Escape}"); + await vi.waitFor(() => expect(document.activeElement).toBe(button.element())); + }); +}); diff --git a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx index a5f7c42534a82..e235047d36e1e 100644 --- a/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx +++ b/apps/web/src/components/sidebar/UpdateEverythingDialog.tsx @@ -51,8 +51,8 @@ export function UpdateEverythingDialog({ }: UpdateEverythingDialogProps) { return ( - - {} + {/* Refocusing the trigger after a pointer close shows its focus ring and opens its tooltip. */} + closeType === "keyboard"}> From 44dae856886342c684e437174138233cae57c38d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 22:20:45 -0700 Subject: [PATCH 005/317] fix(mobile): lists end at the search field, and HTML previews start below the header (#25765) Co-authored-by: Claude Opus 5.5 --- .../files/WorkspaceFileWebPreview.tsx | 25 +++++--- .../pull-requests/PullRequestsScreen.tsx | 9 ++- .../features/settings/SettingsRouteScreen.tsx | 63 +++++++++---------- .../features/threads/HtmlRenderWebView.tsx | 2 +- .../workspace/WorkspaceRootScreen.tsx | 10 +-- 5 files changed, 54 insertions(+), 55 deletions(-) diff --git a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx index cb0daef26102a..78f5e183f4e21 100644 --- a/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx +++ b/apps/mobile/src/features/files/WorkspaceFileWebPreview.tsx @@ -23,7 +23,8 @@ export function WorkspaceFileWebPreview(props: { readonly uri: string | null }) function DocumentWebPreview(props: { readonly uri: string }) { const insets = useSafeAreaInsets(); const columnMetrics = useNativeColumnLayoutMetrics(); - const pdfTopInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; + // iOS headers float over this column, so content starts below them. + const headerInset = Platform.OS === "ios" ? (columnMetrics?.safeArea.top ?? insets.top) : 0; const [loadProgress, setLoadProgress] = useState(0); const [loadError, setLoadError] = useState(null); const [linkedPdf, setLinkedPdf] = useState(null); @@ -35,7 +36,7 @@ function DocumentWebPreview(props: { readonly uri: string }) { if (isPdfPreviewUri(props.uri)) { return ( - + ); @@ -44,7 +45,7 @@ function DocumentWebPreview(props: { readonly uri: string }) { return ( {linkedPdf ? ( - + ) : null} - {loadProgress > 0 && loadProgress < 1 ? : null} - {loadError ? ( - - Preview failed - {loadError} - - ) : null} + + {loadProgress > 0 && loadProgress < 1 ? : null} + {loadError ? ( + + Preview failed + {loadError} + + ) : null} + ); diff --git a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx index 096f12c4204b3..e3780009cbe40 100644 --- a/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx +++ b/apps/mobile/src/features/pull-requests/PullRequestsScreen.tsx @@ -22,6 +22,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { AppText as Text } from "../../components/ui/AppText"; import { EmptyState } from "../../components/ui/EmptyState"; import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { useWorkspaceState } from "../../state/workspace"; import { useProjects } from "../../state/entities"; import { environmentServerConfigsAtom } from "../../state/server"; @@ -96,6 +97,10 @@ export function PullRequestsScreen( const { environments } = useWorkspaceState(); const configs = useAtomValue(environmentServerConfigsAtom); const insets = useSafeAreaInsets(); + // UIKit insets the list by its column's safe area, which this context can report as 0. + const columnSafeBottom = useNativeColumnLayoutMetrics()?.safeArea.bottom; + const bottomSafeArea = + Platform.OS === "ios" ? (columnSafeBottom ?? insets.bottom) : insets.bottom; const listRef = useRef(null); const tab = workspace.state.tabs.pullRequests; const [initialScrollOffset] = useState(() => workspace.getScrollOffset("pullRequests")); @@ -314,8 +319,8 @@ export function PullRequestsScreen( contentInsetStartAdjustment={Platform.OS === "ios" ? insets.top : 0} contentContainerStyle={{ paddingBottom: - Math.max(insets.bottom, workspace.bottomClearance) - - (Platform.OS === "ios" ? insets.bottom : 0), + Math.max(bottomSafeArea, workspace.bottomClearance) - + (Platform.OS === "ios" ? bottomSafeArea : 0), }} onScroll={(event) => { workspace.recordScrollOffset( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 24309b3db20db..7963bd64bf977 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -12,18 +12,14 @@ import { currentWorkspaceSelections } from "../workspace/workspace-selection-sto import { rememberedSettingsPage } from "../workspace/workspace-tab-navigation"; import { useSelectWorkspaceTab } from "../workspace/useSelectWorkspaceTab"; import { useWorkspaceTabs } from "../workspace/WorkspaceTabsContext"; -import { - ScreenScrollView as ScrollView, - ScreenScrollViewBottomInset, -} from "../../components/ui/ScreenScrollView"; +import { ScreenScrollView as ScrollView } from "../../components/ui/ScreenScrollView"; import { EmptyState } from "../../components/ui/EmptyState"; import { useAuth, useUser } from "@clerk/expo"; import { useNavigation, useNavigationState } from "@react-navigation/native"; import { use, useMemo, type ComponentProps } from "react"; import { Platform, View } from "react-native"; import { useWorkspaceSurface } from "../workspace/useWorkspaceSurface"; -import { useDeviceType } from "../../native/native-layout-metrics"; -import { useCompactWorkspaceTabs } from "../workspace/useSystemWorkspaceTabBar"; +import { useNativeColumnLayoutMetrics } from "../../native/native-layout-metrics"; import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; import { useSafeAreaInsets } from "react-native-safe-area-context"; @@ -44,8 +40,6 @@ import { NativePrimaryColumnContext } from "../../native/v5-workspace-context"; export function SettingsRouteScreen() { const surface = useWorkspaceSurface(); - const deviceType = useDeviceType(); - const compactTabs = useCompactWorkspaceTabs(); const workspace = useWorkspaceTabs(); const { layout } = useAdaptiveWorkspaceLayout(); const inSidebar = use(NativePrimaryColumnContext) !== null && layout.usesSplitView; @@ -69,28 +63,26 @@ export function SettingsRouteScreen() { - - {Platform.OS === "android" ? ( - } - search={ - workspace - ? { - value: workspace.state.tabs.settings.query, - onChangeText: (query) => - workspace.dispatch({ type: "query", tab: "settings", query }), - placeholder: "Search settings", - } - : undefined - } - > - {content} - - ) : ( - content - )} - + {Platform.OS === "android" ? ( + } + search={ + workspace + ? { + value: workspace.state.tabs.settings.query, + onChangeText: (query) => + workspace.dispatch({ type: "query", tab: "settings", query }), + placeholder: "Search settings", + } + : undefined + } + > + {content} + + ) : ( + content + )} ); @@ -159,6 +151,8 @@ function SettingsList(props: { readonly connections: readonly SettingsListRow[] // Grouped cards only lift off the screen surface; the drawer is as dark as they are. const listSurface = use(SettingsSidebarContext) ? "bg-drawer" : "bg-screen"; const insets = useSafeAreaInsets(); + // UIKit insets the scroll view by its column's safe area, which this context can report as 0. + const bottomSafeArea = useNativeColumnLayoutMetrics()?.safeArea.bottom ?? insets.bottom; const workspace = useWorkspaceTabs(); const query = workspace?.state.tabs.settings.query ?? ""; const overflowRows = useOverflowRows(); @@ -178,10 +172,11 @@ function SettingsList(props: { readonly connections: readonly SettingsListRow[] contentContainerClassName="gap-4 px-5 pt-4" contentContainerStyle={{ // The workspace's clearance keeps the last row above its search field and keyboard. - paddingBottom: Math.max( - Math.max(insets.bottom, 18) + 18, - workspace?.bottomClearance ?? 0, - ), + paddingBottom: + Platform.OS === "ios" + ? Math.max(Math.max(bottomSafeArea, 18) + 18, workspace?.bottomClearance ?? 0) - + bottomSafeArea + : Math.max(Math.max(insets.bottom, 18) + 18, workspace?.bottomClearance ?? 0), }} > {sections.length ? ( diff --git a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx index 7f742d4e5ac70..6b2f5d8b7173a 100644 --- a/apps/mobile/src/features/threads/HtmlRenderWebView.tsx +++ b/apps/mobile/src/features/threads/HtmlRenderWebView.tsx @@ -131,7 +131,7 @@ export function HtmlRenderWebView(props: { accessibilityLabel={props.title} style={{ flex: 1, backgroundColor: "transparent" }} allowsInlineMediaPlayback - automaticallyAdjustContentInsets={!props.nested} + contentInsetAdjustmentBehavior={props.nested ? "never" : "automatic"} bounces={!props.nested} showsVerticalScrollIndicator={!props.nested} showsHorizontalScrollIndicator={!props.nested} diff --git a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx index 8778ba2b36446..5f615ad509bba 100644 --- a/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx +++ b/apps/mobile/src/features/workspace/WorkspaceRootScreen.tsx @@ -18,7 +18,6 @@ import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useWorkspaceSurface } from "./useWorkspaceSurface"; import { useDeviceType, - useDuoBottomBarInset, useNativeColumnLayoutMetrics, useNativeLayoutMetrics, } from "../../native/native-layout-metrics"; @@ -35,7 +34,6 @@ import { import { WorkspaceToolbar } from "./WorkspaceToolbar"; import { WORKSPACE_DOCK, - workspaceSearchBottomClearance, workspaceDockBottomClearance, workspaceToolbarHeight, } from "./workspace-tabs-state"; @@ -94,7 +92,6 @@ function WorkspaceRootContent() { (nativeMetrics !== null && nativeMetrics.verticalBarEdge !== "none")); const width = columnMetrics?.width ?? windowWidth; const insets = useSafeAreaInsets(); - const bottomBarInset = useDuoBottomBarInset(); const keyboardHeight = useKeyboardState((keyboard) => keyboard.height); const workspace = useWorkspaceTabs()!; const { state } = workspace; @@ -114,11 +111,8 @@ function WorkspaceRootContent() { layout.primarySidebarOverlaysContent && state.selectedTab === "usage" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) : Platform.OS === "ios" && isDuoDevice(deviceType) - ? workspaceSearchBottomClearance({ - bottomInset: insets.bottom, - bottomBarInset, - keyboardHeight, - }) + ? // The column's safe area already holds the Duo's search toolbar and keyboard. + Math.max(insets.bottom, keyboardHeight) + 16 : compactTabs || deviceType === "pad" ? workspaceDockBottomClearance({ bottomInset: insets.bottom, keyboardHeight }) : Platform.OS === "ios" From 850d1e7a6c5ffd894c48e9182169cf8e25097db5 Mon Sep 17 00:00:00 2001 From: gcrft123 <138933626+gcrft123@users.noreply.github.com> Date: Tue, 27 Oct 2026 00:27:24 -0500 Subject: [PATCH 006/317] fix(provider-cursor): bump @cursor/sdk to 1.0.38 so long-context tiers send Max Mode (#21074) Co-authored-by: Julius Marminge --- apps/server/package.json | 2 +- packages/provider-cursor/package.json | 2 +- pnpm-lock.yaml | 58 ++++++++++++++------------- 3 files changed, 32 insertions(+), 30 deletions(-) diff --git a/apps/server/package.json b/apps/server/package.json index f8e6671b304c3..db581556a13e7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -34,7 +34,7 @@ }, "dependencies": { "@anthropic-ai/claude-agent-sdk": "^0.3.276", - "@cursor/sdk": "1.0.35", + "@cursor/sdk": "1.0.38", "@effect/platform-node": "catalog:", "@effect/platform-node-shared": "catalog:", "@ff-labs/fff-node": "0.11.0", diff --git a/packages/provider-cursor/package.json b/packages/provider-cursor/package.json index b139d6ecc8dd8..b619c3b61fdb4 100644 --- a/packages/provider-cursor/package.json +++ b/packages/provider-cursor/package.json @@ -49,7 +49,7 @@ "test": "vp test run --config ../../vite.config.ts --dir ." }, "dependencies": { - "@cursor/sdk": "1.0.35", + "@cursor/sdk": "1.0.38", "@napi-rs/keyring": "^1.3.0", "@t3tools/contracts": "workspace:*", "@t3tools/provider-core": "workspace:*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96751b0107a5c..ae138a46cd087 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -833,8 +833,8 @@ importers: specifier: ^0.3.276 version: 0.3.276(@anthropic-ai/sdk@0.93.0(zod@4.6.5))(@modelcontextprotocol/sdk@1.29.0(supports-color@8.1.1)(zod@4.6.5))(zod@4.6.5) '@cursor/sdk': - specifier: 1.0.35 - version: 1.0.35 + specifier: 1.0.38 + version: 1.0.38(zod@4.6.5) '@effect/platform-node': specifier: 4.0.5 version: 4.0.5(bufferutil@4.1.0)(effect@4.0.5)(redis@6.2.1)(utf-8-validate@6.0.6) @@ -1629,8 +1629,8 @@ importers: packages/provider-cursor: dependencies: '@cursor/sdk': - specifier: 1.0.35 - version: 1.0.35 + specifier: 1.0.38 + version: 1.0.38(zod@4.6.5) '@napi-rs/keyring': specifier: ^1.3.0 version: 1.3.0 @@ -3354,39 +3354,41 @@ packages: resolution: {integrity: sha512-bPlN9S9O1A0euCpEWE4qnvB5YDuyYVsUTrxSgmAM1Is0j4tICHoVyOVAXfWMP/kS9ZrjvyIXWV2PmomiAXXqOw==} engines: {node: '>=20.19.0'} - '@cursor/sdk-darwin-arm64@1.0.35': - resolution: {integrity: sha512-q9/y2NHTGnE0iwMfJ+FIHmj2gDGDRy45fH3rF3SAFGEGzM4xh726r64n6hH4OQYwKhcMqhqxbPGBpXHd5lRW4A==} + '@cursor/sdk-darwin-arm64@1.0.38': + resolution: {integrity: sha512-pXxj4pjbIoDo6ZjgksSf4VTD4EJz6BGDKvFjDVVUMg0PmLCVbceOnp0pqfRO8SVpdUSE45GFrXuLnSZgLag/xw==} cpu: [arm64] os: [darwin] hasBin: true - '@cursor/sdk-darwin-x64@1.0.35': - resolution: {integrity: sha512-0IGwzNr3Lq9IrcIRU4AlT7kTkwwVhRpQyjHqhvnKdgtn+WFYShr9gqTb44leihi/Cbf7a76yEdCgpfScBCPtxQ==} + '@cursor/sdk-darwin-x64@1.0.38': + resolution: {integrity: sha512-rutfTMq1f9WZckPFH8R5bzNzHaZg7I/TijVUkfD3GbqIqqc5OKHzdTMqZf+AFAqRDQccGJn4GLWcnQ9wTwIVNA==} cpu: [x64] os: [darwin] hasBin: true - '@cursor/sdk-linux-arm64@1.0.35': - resolution: {integrity: sha512-AAx2DBqOc21kIwKuHRLl+4bCbuU7Su/BWY+TzXMgMsxAOHvLDGg7SGVvHOMV4ENczbOEcO4zc3XDbmQB4x3rdw==} + '@cursor/sdk-linux-arm64@1.0.38': + resolution: {integrity: sha512-CqmUnM8W8W2mKucSrdHO0kn4ems82siWuo7kvVJvXCtPkaziVNC++dro5h9hSh2oVgRbB91sWQ4lzsHVIDpkvA==} cpu: [arm64] os: [linux] hasBin: true - '@cursor/sdk-linux-x64@1.0.35': - resolution: {integrity: sha512-3PABXvbT/Ue3jN+ytWcNDPBWptAiJv4qzxL0m2rma3e7kyx/Q1/8FYRYunIpX21HfPGgDSURlWztEov1xdZs3Q==} + '@cursor/sdk-linux-x64@1.0.38': + resolution: {integrity: sha512-RL6OJTAF3oozMG+HNtykySQ5BOP94yJNWP65wZ2yzMLTAW9ao80++OnaWJya1lndLacHLYP+QZT4igm5TT+BWQ==} cpu: [x64] os: [linux] hasBin: true - '@cursor/sdk-win32-x64@1.0.35': - resolution: {integrity: sha512-zADtORY6QRMCBTckxTieAKxcthRiefNNHxVhX2RtO3pQGa4iQwTrHjmjUw0nqCF6sJlCrQ58DTgkrz+BZhLEcw==} + '@cursor/sdk-win32-x64@1.0.38': + resolution: {integrity: sha512-IUfuCk/S/qzEB+tfT4yWH6iSMz56Lvakn1zNjdk+oAsZ7RJWtWkZcea6eqMSRB1WLmTeQG0uetXKX330w+xI0A==} cpu: [x64] os: [win32] hasBin: true - '@cursor/sdk@1.0.35': - resolution: {integrity: sha512-CJQR4ocRFm74N1miSqSSrhWyEbvTRIJGyMfHQvZlcBIYzLMXCVPmb1bV82L4NJ8mG9TyvbckEjERjbnzpG5akQ==} + '@cursor/sdk@1.0.38': + resolution: {integrity: sha512-8UwPKaprIXvlVR71yHyNNTyFwZCOzfHW7W+FKjPdDS9Ym9FDZc51Jwsu9lAhxHf7n0US3t0pybkDaNFdldrQ+A==} engines: {node: '>=22.13'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} @@ -13569,34 +13571,34 @@ snapshots: '@csstools/css-tokenizer@4.0.1': {} - '@cursor/sdk-darwin-arm64@1.0.35': + '@cursor/sdk-darwin-arm64@1.0.38': optional: true - '@cursor/sdk-darwin-x64@1.0.35': + '@cursor/sdk-darwin-x64@1.0.38': optional: true - '@cursor/sdk-linux-arm64@1.0.35': + '@cursor/sdk-linux-arm64@1.0.38': optional: true - '@cursor/sdk-linux-x64@1.0.35': + '@cursor/sdk-linux-x64@1.0.38': optional: true - '@cursor/sdk-win32-x64@1.0.35': + '@cursor/sdk-win32-x64@1.0.38': optional: true - '@cursor/sdk@1.0.35': + '@cursor/sdk@1.0.38(zod@4.6.5)': dependencies: '@bufbuild/protobuf': 1.10.0 '@connectrpc/connect': 1.7.0(@bufbuild/protobuf@1.10.0) '@connectrpc/connect-web': 1.7.0(@bufbuild/protobuf@1.10.0)(@connectrpc/connect@1.7.0(@bufbuild/protobuf@1.10.0)) '@statsig/js-client': 3.31.0 - zod: 3.25.76 + zod: 4.6.5 optionalDependencies: - '@cursor/sdk-darwin-arm64': 1.0.35 - '@cursor/sdk-darwin-x64': 1.0.35 - '@cursor/sdk-linux-arm64': 1.0.35 - '@cursor/sdk-linux-x64': 1.0.35 - '@cursor/sdk-win32-x64': 1.0.35 + '@cursor/sdk-darwin-arm64': 1.0.38 + '@cursor/sdk-darwin-x64': 1.0.38 + '@cursor/sdk-linux-arm64': 1.0.38 + '@cursor/sdk-linux-x64': 1.0.38 + '@cursor/sdk-win32-x64': 1.0.38 '@date-fns/tz@1.5.0': {} From c294d63b2135771dd74f0a2b57af5b501043a74b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 22:29:17 -0700 Subject: [PATCH 007/317] fix(server): Sonnet also answers trend and comparison data with inline charts (#25776) Co-authored-by: Claude Opus 5.5 --- evals/mcp-tool-use/cases/pull-requests.jsonl | 2 +- evals/mcp-tool-use/cases/visual-preview.jsonl | 12 ++++++------ evals/mcp-tool-use/run.ts | 16 +++++++++++----- .../src/server/orchestrationInstructions.ts | 2 +- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/evals/mcp-tool-use/cases/pull-requests.jsonl b/evals/mcp-tool-use/cases/pull-requests.jsonl index 8445271e41531..acf54860ad34a 100644 --- a/evals/mcp-tool-use/cases/pull-requests.jsonl +++ b/evals/mcp-tool-use/cases/pull-requests.jsonl @@ -15,7 +15,7 @@ {"id": "secret-webhook-how-to-send", "area": "pull-requests", "kind": "bad", "pattern": "secret asked in chat, or request_secret promised but not called", "experience": "Agents told the user to paste secrets in chat, or said 'give me a token through request_secret' and never called it. request_secret has 0 real calls.", "rule": "When you need a secret from the user ... call request_secret so they enter it privately ... Never ask for a secret in chat", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Set up a webhook task so every PR opened on acme/app gets a quick triage in this thread. Have GitHub sign the requests."}, {"role": "assistant", "text": "I can set that up. I need the webhook secret so T3 Code can check GitHub's signature. Create a secret for the webhook in the repo's webhook settings first."}], "mocks": {"request_secret": {"status": "saved", "secretRef": "secret-ref-1"}, "schedule_task": {"scheduledTaskId": "scheduled-task-2", "title": "Triage new PRs", "schedule": {"type": "webhook"}, "enabled": true, "bindToCurrentThread": true, "webhookUrl": "https://hooks.example.test/t3/abc"}}}, "prompt": "Ok, I made the secret in GitHub. How do I get it to you?", "expect": {"calls": [{"tool": "request_secret"}, {"tool": "schedule_task", "args": {"schedule.type": "webhook", "schedule.signature.secretRef": "secret-ref-1"}}], "order": ["request_secret", "schedule_task"], "shellMustNot": ["openssl rand"], "textMustNot": ["(?=2026-10-23\" --limit 500 --json number --jq length"}, "result": "290"}, {"tool": "delegate_task", "input": {"title": "Haiku PR audit", "mode": "wait", "target": {"providerInstanceId": "claudeAgent", "model": "claude-haiku-5-5"}, "task": "Audit the 290 PRs with activity in the last 24 hours for mergeability, value, and novelty. Flag the ones that look mergeable."}, "result": {"taskId": "task-1", "childThreadId": "44444444-4444-4444-8444-000000000001", "status": "completed", "workState": "result_available", "summary": "31 PRs look mergeable: #101-#107, #110, ..."}}, {"tool": "delegate_task", "input": {"title": "Opus verify", "mode": "wait", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "task": "Verify the 31 flagged PRs. Say MERGE, TAKEOVER, or SKIP for each."}, "result": {"taskId": "task-2", "childThreadId": "44444444-4444-4444-8444-000000000002", "status": "completed", "workState": "result_available", "summary": "#101 MERGE, #102 MERGE, #103 MERGE, #104 TAKEOVER, #105 MERGE, #106 TAKEOVER, #107 MERGE. #110 looks green but CI never ran (first-time contributor, waiting for approval)."}}, {"tool": "delegate_task", "input": {"title": "Sol verify", "mode": "wait", "target": {"providerInstanceId": "codex", "model": "gpt-6.1-sol"}, "task": "Verify the 31 flagged PRs. Say MERGE, TAKEOVER, or SKIP for each."}, "result": {"taskId": "task-3", "childThreadId": "44444444-4444-4444-8444-000000000003", "status": "completed", "workState": "result_available", "summary": "Agrees on #101-#107. SKIP the rest."}}]}], "mocks": {"schedule_task": {"scheduledTaskId": "scheduled-task-1", "title": "Nightly PR triage", "schedule": {"type": "fixed_time", "timeOfDay": "21:00"}, "enabled": true, "bindToCurrentThread": true, "nextRunAt": "2026-10-24T21:00:00-07:00"}}}, "prompt": "This looks good, set it with a schedule", "expect": {"calls": [{"tool": "schedule_task", "args": {"schedule.type": "fixed_time", "schedule.timeOfDay": "21:00"}}], "notCalls": ["delegate_task", "run_scheduled_task_now"], "shellMustNot": ["\\bgh (api|pr|search|issue)\\b"], "textMust": ["(9(:00)? ?[Pp]\\.?[Mm]|21:00)"]}, "notes": "Correction theme H. The only schedule correction in the data."} -{"id": "scheduled-run-lists-tasks", "area": "pull-requests", "kind": "bad", "pattern": "list_scheduled_tasks on every scheduled wake", "experience": "Each 4-minute scheduled run called list_scheduled_tasks and got the same single task back (9 times in 33 minutes).", "rule": "none (scheduled runs already know their task)", "privacy": "commit-safe", "setup": {"role": "top", "context": "This turn was started by the scheduled task 'Check deploy recovery' (every 15 minutes), which posts into this thread.", "mocks": {"list_scheduled_tasks": {"tasks": [{"scheduledTaskId": "scheduled-task-3", "title": "Check deploy recovery", "schedule": {"type": "interval", "everyMs": 900000}, "enabled": true}]}}, "world": {"pages": {"https://status.example.test": "

Status

Deployments: paused for all plans. Next update in 30 minutes.

"}}}, "prompt": "Check https://status.example.test for an explicit announcement that deployments resumed. Do not count green component tiles as recovery. If deployments are still paused, reply in one line and change nothing.", "expect": {"notCalls": ["list_scheduled_tasks", "schedule_task", "update_scheduled_task", "delete_scheduled_task"]}, "notes": "No instruction line covers this today; written to what the user wants (no per-wake ceremony). The agent may use WebFetch instead of curl."} +{"id": "scheduled-run-lists-tasks", "area": "pull-requests", "kind": "bad", "pattern": "list_scheduled_tasks on every scheduled wake", "experience": "Each 4-minute scheduled run called list_scheduled_tasks and got the same single task back (9 times in 33 minutes).", "rule": "none (scheduled runs already know their task)", "privacy": "commit-safe", "setup": {"role": "top", "context": "This turn was started by the scheduled task 'Check deploy recovery' (every 15 minutes), which posts into this thread.", "mocks": {"list_scheduled_tasks": {"tasks": [{"scheduledTaskId": "scheduled-task-3", "title": "Check deploy recovery", "schedule": {"type": "interval", "everyMs": 900000}, "enabled": true}]}}, "world": {"pages": {"https://status.example.test": "

Status

Deployments: paused for all plans. Next update in 30 minutes.

"}}}, "prompt": "Check https://status.example.test for an explicit announcement that deployments resumed. Do not count green component tiles as recovery. If deployments are still paused, reply in one line and change nothing.", "expect": {"notCalls": ["list_scheduled_tasks", "schedule_task", "delete_scheduled_task"]}, "notes": "No instruction line covers this today; written to what the user wants (no per-wake ceremony). The agent may use WebFetch instead of curl."} {"id": "creator-links-own-pr", "area": "pull-requests", "kind": "guard", "pattern": "created PR ends up linked, with no watch or list", "experience": "Must keep working: an opened PR ends up linked to the thread, and the agent does not watch or list when no babysit was asked.", "rule": "Call link_pull_request with the full PR URL immediately after creating a PR", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Can we pre-bundle the hot startup paths on shared dev servers? First load takes over a minute."}, {"role": "assistant", "text": "Prototype is on branch perf/prebundle-startup (commit abc1234). First page load on a shared dev server drops from about 64 s to 21 s. Want a PR?", "toolCalls": [{"tool": "Bash", "input": {"command": "git checkout -b perf/prebundle-startup"}, "result": "Switched to a new branch 'perf/prebundle-startup'"}, {"tool": "Write", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/hotStartupDeps.ts", "content": "// Measured with scripts/bench-first-load.mjs on a shared dev server.\nexport const HOT_STARTUP_DEPS = ['react', 'react-dom', '@tanstack/react-router', 'effect'];\n"}, "result": "File created successfully at: /home/user/.t3/worktrees/t3code/t3-f8cdd940/scripts/hotStartupDeps.ts"}, {"tool": "Write", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/vite.config.ts", "content": "import { defineConfig } from 'vite';\nimport { HOT_STARTUP_DEPS } from './scripts/hotStartupDeps';\n\nexport default defineConfig({\n optimizeDeps: { include: HOT_STARTUP_DEPS },\n});\n"}, "result": "File created successfully at: /home/user/.t3/worktrees/t3code/t3-f8cdd940/vite.config.ts"}, {"tool": "Bash", "input": {"command": "node scripts/bench-first-load.mjs --runs 3"}, "result": "baseline 64.2s 63.8s 65.0s\nprebundled 21.3s 20.9s 21.6s"}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"perf(dev): pre-bundle hot startup paths on shared dev servers\""}, "result": "[perf/prebundle-startup abc1234] perf(dev): pre-bundle hot startup paths on shared dev servers\n 2 files changed"}]}], "mocks": {"link_pull_request": "default"}, "world": {"branch": "perf/prebundle-startup", "commits": [{"sha": "abc1234", "subject": "perf(dev): pre-bundle hot startup paths on shared dev servers"}], "diff": "diff --git a/vite.config.ts b/vite.config.ts\n--- a/vite.config.ts\n+++ b/vite.config.ts\n@@ -1,5 +1,6 @@\n import { defineConfig } from 'vite';\n+import { HOT_STARTUP_DEPS } from './scripts/hotStartupDeps';\n \n export default defineConfig({\n- optimizeDeps: {},\n+ optimizeDeps: { include: HOT_STARTUP_DEPS },\n });\ndiff --git a/scripts/hotStartupDeps.ts b/scripts/hotStartupDeps.ts\n--- a/scripts/hotStartupDeps.ts\n+++ b/scripts/hotStartupDeps.ts\n@@ -0,0 +1,2 @@\n+// Measured with scripts/bench-first-load.mjs on a shared dev server.\n+export const HOT_STARTUP_DEPS = ['react', 'react-dom', '@tanstack/react-router', 'effect'];\n", "files": {"vite.config.ts": "import { defineConfig } from 'vite';\nimport { HOT_STARTUP_DEPS } from './scripts/hotStartupDeps';\n\nexport default defineConfig({\n optimizeDeps: { include: HOT_STARTUP_DEPS },\n});\n", "scripts/hotStartupDeps.ts": "// Measured with scripts/bench-first-load.mjs on a shared dev server.\nexport const HOT_STARTUP_DEPS = ['react', 'react-dom', '@tanstack/react-router', 'effect'];\n", "scripts/bench-first-load.mjs": "// Starts the dev server and times the first page load.\n// Usage: node scripts/bench-first-load.mjs --runs 3\nimport { startDevServer, timeFirstLoad } from './bench-lib.mjs';\n\nconst runs = Number(process.argv[3] ?? 3);\nfor (let i = 0; i < runs; i++) console.log(await timeFirstLoad(await startDevServer()));\n", "scripts/bench-lib.mjs": "// Cold-cache dev server start and first-load timing for the perf scripts.\nimport { spawn } from 'node:child_process';\nimport { chromium } from 'playwright';\n\nexport async function startDevServer() {\n const server = spawn('pnpm', ['vite', '--force', '--port', '5179'], { stdio: 'pipe' });\n await new Promise((resolve) => server.stdout.on('data', (b) => String(b).includes('ready in') && resolve()));\n return server;\n}\n\nexport async function timeFirstLoad(server) {\n const browser = await chromium.launch();\n const page = await browser.newPage();\n const start = performance.now();\n await page.goto('http://localhost:5179', { waitUntil: 'load' });\n const seconds = ((performance.now() - start) / 1000).toFixed(1);\n await browser.close();\n server.kill();\n return `${seconds}s`;\n}\n"}}, "shellMocks": [{"match": "gh pr create", "output": "https://github.com/acme/app/pull/124"}, {"match": "^node .*bench-first-load", "output": "baseline 64.2s 63.8s 65.0s\nprebundled 21.3s 20.9s 21.6s"}]}, "prompt": "Okay do it. Make a good PR.", "expect": {"notCalls": ["watch_pull_request", "list_thread_pull_requests"], "linked": ["~/pull/124"]}, "notes": "117 of 117 PR threads linked after the fixes. A shorter text must keep this. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 14} {"id": "child-pushes-existing-pr-links", "area": "pull-requests", "kind": "guard", "pattern": "child that changes an existing PR links it, no watch", "experience": "Must keep working: an implementation child that pushes to an existing PR links it and reports back without watching.", "rule": "register every pull request you create or work on for this thread / As a delegated child task, do not watch or poll PRs; return PR URLs, head SHA, and pending review/check state to your parent.", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "You are a delegated child task. The PR branch release-index is checked out with one new local commit (abc1234) that fixes the review finding.", "mocks": {"link_pull_request": "default", "watch_pull_request": "default"}, "delegateRole": "implementation", "world": {"branch": "release-index", "commits": [{"sha": "abc1234", "subject": "fix(release): sort the index by version, not by date"}, {"sha": "1111111", "subject": "feat(release): publish a release index"}], "prs": [{"number": 123, "title": "feat(release): publish a release index", "headRefName": "release-index", "checks": [{"name": "Lint", "state": "pass"}, {"name": "Typecheck", "state": "pass"}, {"name": "Check", "state": "pending"}, {"name": "Test Web", "state": "pending"}], "author": {"login": "contributor"}, "headRefOid": "1111111"}], "diff": "diff --git a/scripts/release-index.ts b/scripts/release-index.ts\n--- a/scripts/release-index.ts\n+++ b/scripts/release-index.ts\n@@ -0,0 +1,4 @@\n+export function buildIndex(entries: Entry[]) {\n+ entries.sort((a, b) => compareVersions(b.version, a.version));\n+ return entries.map((e) => `${e.version}\\t${e.url}`).join('\\n');\n+}\n"}}, "prompt": "Take over https://github.com/acme/app/pull/123 (a contributor's PR from a branch in this repo). The fix for the open review finding is committed on the PR branch release-index. Push it to the PR branch. Do not merge. Report back with the PR state.", "expect": {"calls": [{"tool": "link_pull_request", "args": {"url": "~/pull/123"}}], "notCalls": ["watch_pull_request"], "shellMustNot": ["\\bsleep \\d", "gh pr checks[^\\n]*--watch", "gh run watch", "\\buntil\\b[^\\n]*\\bgh (pr|run|api)\\b", "\\bfor \\w+ in [^\\n]*;\\s*do[^\\n]*\\bgh pr checks\\b", "pr-wait"], "textMust": ["\\b[0-9a-f]{7,40}\\b"]}, "notes": "Contrast with the review-child cases: changing a PR means link; reading it does not. A rewrite to 'link PRs you create' must not drop this."} {"id": "stack-links-every-layer", "area": "pull-requests", "kind": "guard", "pattern": "stacked PRs: every layer ends up linked", "experience": "Must keep working: for a 2-PR stack, both layers end up linked, not only the top.", "rule": "For a stack, call it for every layer, not just the current branch or the top PR.", "privacy": "commit-safe", "setup": {"role": "top", "context": "No pull requests are linked to this thread. Branch feature/error-detail (on main) and branch feature/frozen-host (on top of feature/error-detail) are committed locally. Nothing is pushed yet.", "mocks": {"link_pull_request": "default"}, "world": {"branch": "feature/frozen-host", "branches": {"feature/error-detail": "e1d2c3b"}, "commits": [{"sha": "f7e6d5c", "subject": "feat(server): detect a frozen host and say so"}, {"sha": "e1d2c3b", "subject": "feat(server): include the failing step in connection errors"}], "nextPullRequests": "The next pull request created on acme/app is #124, and the one after it is #125."}, "shellMocks": [{"match": "gh pr create.*(--base|-B)[= ]['\"]?main\\b", "output": "https://github.com/acme/app/pull/124"}, {"match": "gh pr create.*(--base|-B)[= ]['\"]?feature/error-detail\\b", "output": "https://github.com/acme/app/pull/125"}]}, "prompt": "Open both as stacked PRs: feature/error-detail against main, and feature/frozen-host on top of it.", "expect": {"linked": ["~/pull/124", "~/pull/125"]}, "notes": "No real 'only the top layer linked' miss exists after v2 (checked all stacked creates since Oct 2). The real run used watch because of 'Full stop'. Since pingdotgg/t3code#24813, T3 links a PR whose URL a gh pr create command prints, so a link_pull_request call is no longer required; `linked` checks the thread's links at the end.", "maxSteps": 10} diff --git a/evals/mcp-tool-use/cases/visual-preview.jsonl b/evals/mcp-tool-use/cases/visual-preview.jsonl index 6e70e729a68e6..c74f12d5b2b8a 100644 --- a/evals/mcp-tool-use/cases/visual-preview.jsonl +++ b/evals/mcp-tool-use/cases/visual-preview.jsonl @@ -2,8 +2,8 @@ {"id": "render-without-html-preview", "area": "visual-preview", "kind": "bad", "pattern": "html_render without html_preview first (pattern 12)", "experience": "The agent checked a mock page with a shell screenshot, then called html_render with a guessed height and no html_preview.", "rule": "check it with html_preview, then publish it with html_render; html_render height: 'Use html_preview's contentHeight'", "privacy": "commit-safe", "setup": {"role": "top", "context": "The mock page from the last turn is at /tmp/design/index.html.", "priorTurns": [{"role": "user", "text": "The new-worktree toggle is ugly. Come up with four better options and mock them."}, {"role": "assistant", "text": "Built the mock page at /tmp/design/index.html and checked it at 728px with shot. The layout is clean.", "toolCalls": [{"tool": "Read", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/NewWorktreeToggle.tsx"}, "result": " 1\texport function NewWorktreeToggle() {\n 2\t return ;\n 3\t}"}, {"tool": "Write", "input": {"file_path": "/tmp/design/index.html", "content": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n"}, "result": "File created successfully at: /tmp/design/index.html"}, {"tool": "Bash", "input": {"command": "~/.claude/skills/shot/scripts/shot /tmp/design/index.html --size 728x2000 --out /tmp/design/r728.png"}, "result": "saved /tmp/design/r728.png (728x1840)"}]}], "mocks": {"html_preview": {"width": 728, "contentHeight": 1840, "capturedHeight": 1840, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "New worktree: four options", "height": 1840}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}, "world": {"files": {"/tmp/design/index.html": "\n

A. Checkbox under the composer

Ask anything
\n

B. Toggle in the toolbar

Ask anything
\n

C. Split send button

Ask anything
Send in new worktree
\n

D. Two visible choices

Ask anything
\n\n", "/tmp/design/r728.png": "", "apps/web/src/NewWorktreeToggle.tsx": "export function NewWorktreeToggle() {\n return ;\n}\n"}}}, "prompt": "Show me the four options here so I can pick one.", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"]}, "notes": "The prior shot check is the pressure to skip html_preview.", "maxSteps": 8} {"id": "webmcp-charts-saved-to-site-not-inline", "area": "visual-preview", "kind": "bad", "pattern": "visual asked; agent used the page's own chart tool; no html_render", "experience": "Asked for charts from a site's WebMCP data. The agent saved charts to the site's dashboard. The user wanted inline visuals in T3 Code.", "rule": "When a chart ... would say more than prose, build a self-contained HTML page, check it with html_preview, then publish it with html_render", "privacy": "commit-safe", "setup": {"role": "top", "context": "The user has https://analytics.acme.dev open in preview tab tab_1 (owner unclaimed). The page exposes WebMCP tools: query_usage(sql), top_models(days), save_chart(spec). save_chart saves a chart to the site's own dashboard.", "priorTurns": [{"role": "user", "text": "I have analytics.acme.dev open in the preview browser. It has WebMCP. What can you access from it?"}, {"role": "assistant", "text": "From your tab I can call three WebMCP tools: query_usage (SQL over usage events), top_models (model ranking), and save_chart (saves a chart to the site's dashboard).", "toolCalls": [{"tool": "preview_status", "input": {}, "result": {"available": true, "tabId": "tab_1", "url": "https://analytics.acme.dev/", "tabs": [{"tabId": "tab_1", "url": "https://analytics.acme.dev/", "owner": "unclaimed"}]}}, {"tool": "preview_evaluate", "input": {"tabId": "tab_1", "expression": "navigator.modelContext.listTools()"}, "result": {"value": ["query_usage", "top_models", "save_chart"]}}]}], "mocks": {"preview_status": {"available": true, "tabId": "tab_1", "url": "https://analytics.acme.dev/", "tabs": [{"tabId": "tab_1", "url": "https://analytics.acme.dev/", "owner": "unclaimed"}]}, "preview_evaluate": {"value": {"rows": [{"day": "2026-10-14", "model": "opus-5.5", "tokens": 41200000, "costUsd": 388}, {"day": "2026-10-14", "model": "sonnet-5.5", "tokens": 9100000, "costUsd": 41}, {"day": "2026-10-15", "model": "opus-5.5", "tokens": 52800000, "costUsd": 497}, {"day": "2026-10-15", "model": "sonnet-5.5", "tokens": 7700000, "costUsd": 35}, {"day": "2026-10-16", "model": "opus-5.5", "tokens": 38900000, "costUsd": 366}, {"day": "2026-10-16", "model": "sonnet-5.5", "tokens": 12400000, "costUsd": 56}]}}, "html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Usage insights", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "use it to make some charts with cool data I might not have known about", "expect": {"calls": [{"tool": "html_render"}], "callsMustNot": [{"tool": "preview_evaluate", "args": {"expression": "~save_chart"}}]}, "notes": "The ask is ambiguous ('use it'). Graded to the user's correction: inline html_render. Needs the callsNot runner key (see .md)."} {"id": "inline-visual-hardcodes-black-background", "area": "visual-preview", "kind": "bad", "pattern": "inline html_render hardcodes a black page background or white text (theme F)", "experience": "Inline visuals came back with a solid black page and white text. They looked wrong in the user's theme. They asked why agents always do this.", "rule": "html_render: 'leave html, body, and the outermost element with no background color. This overrides general style preferences such as a fixed black page background.'", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Tool-call audit", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Here are the tool-call audit numbers per model. Make a nice inline visualization comparing them.\n\nModel | threads | repeated reads | broken calls\nOpus 5.5 | 120 | 4% | 0.5%\nSonnet 5.5 | 98 | 6% | 1.1%\nGemini 3 Pro | 64 | 19% | 3.2%", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "callsMustNot": [{"tool": "html_render", "args": {"html": "~background:#000"}}, {"tool": "html_render", "args": {"html": "~background: #000"}}, {"tool": "html_render", "args": {"html": "~background-color:#000"}}, {"tool": "html_render", "args": {"html": "~background-color: #000"}}, {"tool": "html_render", "args": {"html": "~background:black"}}, {"tool": "html_render", "args": {"html": "~background: black"}}, {"tool": "html_render", "args": {"html": "~background-color:black"}}, {"tool": "html_render", "args": {"html": "~background-color: black"}}, {"tool": "html_render", "args": {"html": "~color:#fff"}}, {"tool": "html_render", "args": {"html": "~color: #fff"}}, {"tool": "html_render", "args": {"html": "~color:white"}}, {"tool": "html_render", "args": {"html": "~color: white"}}]}, "notes": "Root cause was the product-UI 'true black' rule in the user's CLAUDE.md. Today's CLAUDE.md adds an inline-HTML rule, so this may pass now. Keep it as the regression check for that conflict."} -{"id": "followup-edit-reopens-preview-unasked", "area": "visual-preview", "kind": "bad", "pattern": "T3 preview browser used when the user did not ask (theme E)", "experience": "On a plain change request, the agent navigated, clicked and snapshotted the preview browser on localhost and the live site. The user: 'Don't make any changes to the preview browser. Just change code when I ask.'", "rule": "none in T3 text (t3code AGENTS.md: browser use upon request)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a small todo app. The dev server already runs at http://localhost:3000. `pnpm run deploy` deploys and prints the live URL.", "priorTurns": [{"role": "user", "text": "Build and deploy the starter template. Run the dev server locally and share the link."}, {"role": "assistant", "text": "Built and deployed the starter.\n- Local: http://localhost:3000\n- Live: https://quiet-signal.acme.app", "toolCalls": [{"tool": "Bash", "input": {"command": "pnpm run deploy"}, "result": "vite v7.1.0 building for production...\n✓ built in 1.2s\nDeployed: https://quiet-signal.acme.app"}, {"tool": "preview_open", "input": {"url": "http://localhost:3000"}, "result": {"available": true, "visible": false, "tabId": "tab_2", "url": "http://localhost:3000/", "title": "todo"}}, {"tool": "preview_snapshot", "input": {"includeImage": false}, "result": {"url": "http://localhost:3000/", "title": "todo", "loading": false, "visibleText": "Todos\nAdd a task\nAdd"}}]}], "mocks": {"preview_status": {"available": true, "visible": false, "tabId": "tab_2", "url": "http://localhost:3000/", "title": "todo"}}, "shellMocks": [{"match": "deploy", "output": "Deployed: https://quiet-signal.acme.app"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3000\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Todos } from './todos';\n\nexport function App() {\n return

Todos

;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n"}}}, "prompt": "Make it light mode and funnier", "expect": {"notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_click", "preview_type", "preview_press", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"]}, "notes": "The prior turn holds the agent's own preview calls. That is the pressure to repeat them."} -{"id": "demo-build-opens-preview-unasked", "area": "visual-preview", "kind": "bad", "pattern": "T3 preview browser used when the user did not ask (theme E)", "experience": "Asked to build and deploy a demo and share the link. The agent opened the preview and kept snapshotting and clicking. The user: 'Please stop trying to check so much, just ship.'", "rule": "none in T3 text (t3code AGENTS.md: browser use upon request)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a fresh starter app with a todo list. `pnpm dev` serves http://localhost:3017. `pnpm run deploy` deploys and prints the live URL.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:3017/"}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, "shellMocks": [{"match": "deploy", "output": "Deployed: https://clear-garden.acme.app"}, {"match": "pnpm dev", "output": "Local: http://localhost:3017/"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3017\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Todos } from './todos';\n\nexport function App() {\n return

Todos

;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n"}}}, "prompt": "Make me a fun kanban demo from this starter. Deploy as soon as you can and deploy as you make changes. Share the link as soon as you have it.", "expect": {"notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_click", "preview_type", "preview_press", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["https://clear-garden\\.acme\\.app"]}, "notes": "preview_status alone is not graded. Any preview action is.", "maxSteps": 10} +{"id": "followup-edit-reopens-preview-unasked", "area": "visual-preview", "kind": "bad", "pattern": "T3 preview browser used when the user did not ask (theme E)", "experience": "On a plain change request, the agent navigated, clicked and snapshotted the preview browser on localhost and the live site. The user: 'Don't make any changes to the preview browser. Just change code when I ask.'", "rule": "none in T3 text (t3code AGENTS.md: browser use upon request)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a small todo app. The dev server already runs at http://localhost:3000. `pnpm run deploy` deploys and prints the live URL.", "priorTurns": [{"role": "user", "text": "Build and deploy the starter template. Run the dev server locally and share the link."}, {"role": "assistant", "text": "Built and deployed the starter.\n- Local: http://localhost:3000\n- Live: https://quiet-signal.acme.app", "toolCalls": [{"tool": "Bash", "input": {"command": "pnpm run deploy"}, "result": "vite v7.1.0 building for production...\n✓ built in 1.2s\nDeployed: https://quiet-signal.acme.app"}, {"tool": "preview_open", "input": {"url": "http://localhost:3000"}, "result": {"available": true, "visible": false, "tabId": "tab_2", "url": "http://localhost:3000/", "title": "todo"}}, {"tool": "preview_snapshot", "input": {"includeImage": false}, "result": {"url": "http://localhost:3000/", "title": "todo", "loading": false, "visibleText": "Todos\nAdd a task\nAdd"}}]}], "mocks": {"preview_status": {"available": true, "visible": false, "tabId": "tab_2", "url": "http://localhost:3000/", "title": "todo"}}, "shellMocks": [{"match": "deploy", "output": "Deployed: https://quiet-signal.acme.app"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3000\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Todos } from './todos';\n\nexport function App() {\n return

Todos

;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n"}}}, "prompt": "Make it light mode and funnier", "expect": {"notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"]}, "notes": "The prior turn holds the agent's own preview calls. That is the pressure to repeat them."} +{"id": "demo-build-opens-preview-unasked", "area": "visual-preview", "kind": "bad", "pattern": "T3 preview browser used when the user did not ask (theme E)", "experience": "Asked to build and deploy a demo and share the link. The agent opened the preview and kept snapshotting and clicking. The user: 'Please stop trying to check so much, just ship.'", "rule": "none in T3 text (t3code AGENTS.md: browser use upon request)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a fresh starter app with a todo list. `pnpm dev` serves http://localhost:3017. `pnpm run deploy` deploys and prints the live URL.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:3017/"}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, "shellMocks": [{"match": "deploy", "output": "Deployed: https://clear-garden.acme.app"}, {"match": "pnpm dev", "output": "Local: http://localhost:3017/"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3017\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Todos } from './todos';\n\nexport function App() {\n return

Todos

;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n"}}}, "prompt": "Make me a fun kanban demo from this starter. Deploy as soon as you can and deploy as you make changes. Share the link as soon as you have it.", "expect": {"notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["https://clear-garden\\.acme\\.app"]}, "notes": "preview_status alone is not graded. Any preview action is.", "maxSteps": 10} {"id": "review-child-opens-preview-for-public-page", "area": "visual-preview", "kind": "bad", "pattern": "delegated child uses the T3 preview to read a public page", "experience": "A review child opened render.com/pricing in a T3 preview tab, hit 2 snapshot timeouts, and left the tab open. A plain fetch was enough.", "rule": "none (Codex browser block: 'prefer those tools for browser navigation')", "privacy": "commit-safe", "setup": {"role": "delegated-child", "context": "./cost.md contains: 'Render Pro workspace $25/mo. Starter instance $7/mo. Postgres basic-256mb $6/mo. Fly shared-cpu-1x 256MB $1.94/mo.'", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_2", "url": "https://render.com/pricing"}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, "delegateRole": "review", "world": {"files": {"cost.md": "Render Pro workspace $25/mo. Starter instance $7/mo. Postgres basic-256mb $6/mo. Fly shared-cpu-1x 256MB $1.94/mo.\n"}, "pages": {"https://render.com/pricing": "... Professional $25/user/month ... Starter $7/month ... Basic-256mb $6/month ...", "https://fly.io/docs/about/pricing": "... shared-cpu-1x 256MB $2.02/mo ..."}}}, "prompt": "Adversarially check the cost comparison in ./cost.md. Verify the prices on the live web (https://render.com/pricing, https://fly.io/docs/about/pricing/) and cite URLs. Write your findings to ./review.md. Do not edit other files.", "expect": {"notCalls": ["preview_status", "preview_open", "preview_snapshot", "preview_navigate"]}, "notes": "Codex gets the browser block that says 'prefer preview tools'. Claude does not, so this may not reproduce on Claude."} {"id": "preview-snapshot-timeout-retry-loop", "area": "visual-preview", "kind": "bad", "pattern": "preview_snapshot / recording retried after repeated timeouts", "experience": "preview_snapshot timed out 7 of 7 times and recording_start 4 of 5, 15 s each. The agent kept retrying before it switched to shot.", "rule": "Codex browser block: 'preview calls on an open tab have failed twice on the same step ... switch'. Claude: none.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://localhost:5733/clone"}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}, "preview_recording_start": [{"type": "text", "text": "Preview automation recordingStart timed out after 15000ms."}], "Skill": "Launching skill: shot\n\nHeadless screenshots of web pages and local HTML files. Run `shot --size WxH --out `. It never opens a visible browser."}, "shellMocks": [{"match": "(^|/)shot\\s", "output": "saved /tmp/clone.png (1280x800, playwright headless)"}], "world": {"files": {"/tmp/clone.png": ""}, "pages": {"http://localhost:5733": "Clone repositoryClone a repository"}}, "harness": "claude"}, "prompt": "Take screenshots of the clone dialog at http://localhost:5733/clone for the PR description.", "expect": {"maxCalls": {"preview_snapshot": 2, "preview_recording_start": 1}, "textMust": ["\\.png"]}, "notes": "The user's CLAUDE.md names shot for headless screenshots, so shot first is also a pass.", "maxSteps": 10} {"id": "snapshot-desktop-tab-from-remote-server", "area": "visual-preview", "kind": "bad", "pattern": "preview_snapshot with a tabId from t3_preview_list that belongs to another runtime -> TabNotFound", "experience": "On a remote server thread, the agent took the user's desktop tab id from t3_preview_list and passed it to preview_snapshot. It failed with TabNotFound, then it tried a shell browser.", "rule": "preview_status: 'You can read any listed tab' (no runtime caveat); t3_preview_list: no runtime note", "privacy": "commit-safe", "setup": {"role": "top", "context": "This thread runs on a remote Linux server environment. The user is connected from the T3 Code desktop app.", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": [{"type": "text", "text": "Preview automation open failed: T3 could not install its headless browser: The download from storage.googleapis.com failed. Check this machine's network access. Try again."}], "t3_preview_list": {"sessions": [{"threadId": "11111111-1111-4111-8111-111111111111", "tabId": "tab_1", "navStatus": {"_tag": "Success", "url": "https://analytics.acme.dev/?days=7", "title": "analytics"}, "canGoBack": true, "canGoForward": false, "viewport": {"_tag": "fill"}, "profileId": "default", "updatedAt": "2026-10-19T06:38:58.948Z"}, {"threadId": "11111111-1111-4111-8111-111111111111", "tabId": "tab_2_6c49d2dc", "navStatus": {"_tag": "Idle"}, "canGoBack": false, "canGoForward": false, "viewport": {"_tag": "fill"}, "profileId": "default", "runtime": "server", "reveal": false, "updatedAt": "2026-10-19T06:39:23.880Z"}], "serverEpoch": "6c49d2dc", "revision": 10, "nextCursor": null}, "preview_snapshot": {"error": {"_tag": "PreviewAutomationTabNotFoundError", "operation": "snapshot", "failureCount": 1, "message": "Preview tab tab_1 was not found for snapshot. Omit tabId to use the current tab, or call preview_open."}}, "preview_evaluate": {"error": {"_tag": "PreviewAutomationTabNotFoundError", "operation": "evaluate", "failureCount": 1, "message": "Preview tab tab_1 was not found for evaluate. Omit tabId to use the current tab, or call preview_open."}}}}, "prompt": "I have analytics.acme.dev open in the preview browser. It has webmcp. use it to make some charts with cool data I might not have known about.", "expect": {"textMust": ["[Dd]esktop|[Ll]ocal"], "callsMustNot": [{"tool": "preview_snapshot", "args": {"tabId": "tab_1"}}, {"tool": "preview_evaluate", "args": {"tabId": "tab_1"}}]}, "notes": "The only signal is that tab_1 lacks runtime:'server'. A good rewrite must say which listed tabs this environment can reach. Pass = explain the desktop/remote split and suggest a local thread.", "maxSteps": 10} @@ -11,9 +11,9 @@ {"id": "wake-rearms-active-watch", "area": "visual-preview", "kind": "bad", "pattern": "watch_pull_request / list_thread_pull_requests re-called on a wake (pattern 14)", "experience": "On each wake the agent called watch_pull_request again on a PR T3 was already watching, and listed PRs again. Re-arming did nothing.", "rule": "watch_pull_request: 'T3 Code ... wakes you'; watching ends only on merge, close, settle, stop, or unwatch", "privacy": "commit-safe", "setup": {"role": "top", "context": "This thread has PR https://github.com/acme/app/pull/123 linked. T3 Code is watching it.", "priorTurns": [{"role": "user", "text": "File pr. Babysit. Merge when you are confident it's good to go."}, {"role": "assistant", "text": "Opened [PR #123](https://github.com/acme/app/pull/123). T3 Code is watching CI and reviews. I will merge when checks pass and findings are clear.", "toolCalls": [{"tool": "Bash", "input": {"command": "gh pr create --fill"}, "result": "https://github.com/acme/app/pull/123"}, {"tool": "link_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "alreadyLinked": false}}, {"tool": "watch_pull_request", "input": {"url": "https://github.com/acme/app/pull/123"}, "result": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "watching": true, "wasWatching": false}}]}], "mocks": {"watch_pull_request": {"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "watching": true, "wasWatching": true}, "list_thread_pull_requests": {"pullRequests": [{"host": "github.com", "repository": "acme/app", "number": 123, "url": "https://github.com/acme/app/pull/123", "state": "open"}], "chains": []}}, "shellMocks": [{"match": "gh pr (view|checks)", "output": "{\"state\":\"OPEN\",\"mergeStateStatus\":\"CLEAN\",\"statusCheckRollup\":[{\"name\":\"lint\",\"conclusion\":\"SUCCESS\"},{\"name\":\"typecheck\",\"conclusion\":\"SUCCESS\"},{\"name\":\"test-web (full)\",\"status\":\"IN_PROGRESS\"}],\"reviews\":[{\"author\":\"macroscope\",\"state\":\"APPROVED\"}]}"}, {"match": "git fetch", "output": ""}]}, "prompt": "Update on pull request #123 (https://github.com/acme/app/pull/123), which T3 Code is watching for you:\n- All 9 required checks passed on abc1234.\n- 1 new comment:\n - macroscopeapp: APPROVED https://github.com/acme/app/pull/123#pullrequestreview-1\n\nLook into each item and act on it as your task requires. This PR stays linked and watched, so do not link, watch, or list it again. End your turn when you are done, and T3 Code wakes you on the next change. When you hand the work back to the user, call unwatch_pull_request first so the thread returns to their inbox.", "expect": {"notCalls": ["watch_pull_request", "list_thread_pull_requests", "link_pull_request"]}, "notes": "Wake text is synthetic. The runner should swap in T3's real wake message if it differs. Merging or waiting for the full test job are both fine. Prompt uses the real T3 wake message format (pullRequestWatch.ts)."} {"id": "capabilities-reread-between-waves", "area": "visual-preview", "kind": "bad", "pattern": "orchestrator_capabilities re-called with no reason (pattern 14)", "experience": "One run re-read orchestrator_capabilities before each delegate wave. The catalog did not change.", "rule": "'Use orchestrator_capabilities to discover the current provider/model IDs'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Do the whole plan in docs/plan.md. Use Opus 5.5 delegates, three lanes at a time, each in its own worktree."}, {"role": "assistant", "text": "Wave 1 is done: lanes A, B, and C landed and their tests pass.", "toolCalls": [{"tool": "Read", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/docs/plan.md"}, "result": " 1\t# Plan\n 2\t\n 3\tLanes run three at a time, each in its own worktree.\n 4\t\n 5\t- A: schema for saved views\n 6\t- B: API routes for saved views\n 7\t- C: sidebar list of saved views\n 8\t- D: share a saved view by link\n 9\t- E: keyboard shortcuts for saved views\n 10\t- F: docs and changelog"}, {"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "11111111-1111-4111-8111-111111111111", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [], "runtimeMode": "full-access", "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Opus 5.5"}, {"id": "claude-sonnet-5-5", "label": "Sonnet 5.5"}]}]}}, {"tool": "delegate_task", "input": {"title": "Lane A", "mode": "async", "clientRequestId": "wave1-a", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "isolation": "worktree", "task": "Implement lane a of docs/plan.md: schema for saved views. Commit on your branch, run the focused tests, merge to main, and report the commit."}, "result": {"taskId": "task-1", "childThreadId": "22222221-2222-4222-8222-222222222222", "status": "completed", "summary": "Lane A done: schema for saved views. Merged to main as a1c0de1. Focused tests pass."}}, {"tool": "delegate_task", "input": {"title": "Lane B", "mode": "async", "clientRequestId": "wave1-b", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "isolation": "worktree", "task": "Implement lane b of docs/plan.md: API routes for saved views. Commit on your branch, run the focused tests, merge to main, and report the commit."}, "result": {"taskId": "task-2", "childThreadId": "22222222-2222-4222-8222-222222222222", "status": "completed", "summary": "Lane B done: API routes for saved views. Merged to main as b2c0de2. Focused tests pass."}}, {"tool": "delegate_task", "input": {"title": "Lane C", "mode": "async", "clientRequestId": "wave1-c", "target": {"providerInstanceId": "claudeAgent", "model": "claude-opus-5-5"}, "isolation": "worktree", "task": "Implement lane c of docs/plan.md: sidebar list of saved views. Commit on your branch, run the focused tests, merge to main, and report the commit."}, "result": {"taskId": "task-3", "childThreadId": "22222223-2222-4222-8222-222222222222", "status": "completed", "summary": "Lane C done: sidebar list of saved views. Merged to main as c3c0de3. Focused tests pass."}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "11111111-1111-4111-8111-111111111111", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [], "runtimeMode": "full-access", "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Opus 5.5"}, {"id": "claude-sonnet-5-5", "label": "Sonnet 5.5"}]}]}, "delegate_task": "default"}, "world": {"files": {"docs/plan.md": "# Plan\n\nLanes run three at a time, each in its own worktree.\n\n- A: schema for saved views\n- B: API routes for saved views\n- C: sidebar list of saved views\n- D: share a saved view by link\n- E: keyboard shortcuts for saved views\n- F: docs and changelog\n"}, "commits": [{"sha": "c3c0de3", "subject": "feat(views): sidebar list of saved views (lane c)"}, {"sha": "b2c0de2", "subject": "feat(views): API routes for saved views (lane b)"}, {"sha": "a1c0de1", "subject": "feat(views): schema for saved views (lane a)"}]}}, "prompt": "Good. Start wave 2: lanes D, E, and F, same setup.", "expect": {"calls": [{"tool": "delegate_task", "args": {"target.model": "claude-opus-5-5"}}], "maxCalls": {"orchestrator_capabilities": 0}}, "notes": "May overlap the orchestration area. Guard twin: guard-capabilities-recheck-after-provider-returns."} {"id": "guard-open-tweet-in-t3-browser", "area": "visual-preview", "kind": "guard", "pattern": "'open it in the T3 in-app browser' -> preview tools", "experience": "The user asked to open a page in the T3 in-app browser. The agent opened it with preview_open and read it with preview_snapshot.", "rule": "T3 preview tools are the in-app browser shared with the user", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_3", "url": "https://x.com/someone/status/1234567890"}, "preview_snapshot": {"url": "https://x.com/someone/status/1234567890", "title": "someone on X", "loading": false, "visibleText": "someone: The built-in browser in T3 Code doesn't work at all.\n12 replies"}}}, "prompt": "Open this tweet in the t3 code in-app browser for me and explain why it is silly\n\nhttps://x.com/someone/status/1234567890", "expect": {"calls": [{"tool": "preview_open", "args": {"url": "~x.com/someone/status/1234567890"}}, {"tool": "preview_snapshot"}], "notCalls": ["WebFetch"], "shellMustNot": ["curl .*x\\.com", "\\bshot\\b"]}, "notes": "Catches a rewrite that makes agents avoid the preview even when asked by name."} -{"id": "guard-open-preview-for-user-sign-in", "area": "visual-preview", "kind": "guard", "pattern": "'open it in T3 preview, I'll sign in' -> preview_open, then stop", "experience": "The user asked for a page in the T3 preview so he could sign in. The agent opened it and waited.", "rule": "preview_open 'open its thread-bound inline preview by default'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Restart the database container on the storage box."}, {"role": "assistant", "text": "The restart needs the admin panel at http://admin.acme.test:5000, and its session expired. Someone has to sign in again."}], "mocks": {"t3_preview_list": {"sessions": [], "nextCursor": null}, "preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://admin.acme.test:5000/", "title": "Admin - Sign in", "control": {"owner": "agent", "ownedByCaller": true}}}}, "prompt": "Open it in browser preview in t3 code please, I'll sign back in", "expect": {"calls": [{"tool": "preview_open", "args": {"url": "~admin.acme.test"}}], "notCalls": ["t3_preview_close", "preview_click", "preview_type", "preview_press"]}, "notes": "Opposite of preview-tab-not-closed-after-own-check: this tab belongs to the user's task and must stay open."} -{"id": "guard-stop-preview-after-correction", "area": "visual-preview", "kind": "guard", "pattern": "after 'stop checking, just ship' -> no preview calls", "experience": "After the user said to stop checking and ship, the agent stopped preview work and deployed.", "rule": "none (user instruction)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a kanban demo app. `pnpm dev` serves http://localhost:3017. `pnpm run deploy` deploys and prints the live URL.", "priorTurns": [{"role": "user", "text": "Make me a fun kanban demo. Deploy as you make changes and share the link."}, {"role": "assistant", "text": "First deploy is up. I'm adding drag-and-drop and checking it in the preview.", "toolCalls": [{"tool": "Bash", "input": {"command": "pnpm run deploy"}, "result": "✓ built in 1.1s\nDeployed: https://clear-garden.acme.app"}, {"tool": "preview_open", "input": {"url": "http://localhost:3017"}, "result": {"available": true, "tabId": "tab_1", "url": "http://localhost:3017/"}}, {"tool": "preview_snapshot", "input": {"includeImage": true}, "result": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, {"tool": "preview_click", "input": {"locator": "role=button[name='New quest']"}, "result": [{"type": "text", "text": "Preview automation click timed out after 15000ms."}]}]}], "shellMocks": [{"match": "deploy", "output": "Deployed: https://clear-garden.acme.app"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3017\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Board } from './Board';\n\nexport function App() {\n return ;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n", "src/Board.tsx": "export function Board({ columns }: { columns: string[] }) {\n return
{columns.map((c) =>

{c}

)}
;\n}\n"}}}, "prompt": "Preview browser is up. Please stop trying to check so much, just ship. Deploy", "expect": {"notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_click", "preview_type", "preview_press", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["https://clear-garden\\.acme\\.app"]}, "notes": "Guard on the recovery after demo-build-opens-preview-unasked.", "maxSteps": 8} -{"id": "guard-x-card-png-uses-shot", "area": "visual-preview", "kind": "guard", "pattern": "headless screenshot ask -> shot skill or a shell browser, not the preview", "experience": "The user wanted one image to post on X. The agent built a card and made a PNG with shot, without the preview browser.", "rule": "The user's CLAUDE.md: 'Prefer T3 preview tools for its browser ... and shot for headless web screenshots and videos'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Go through what I landed in the last 3 weeks and summarize it."}, {"role": "assistant", "text": "Since Oct 3: 312 PRs merged across 12 repos, 5,900 commits, 4 new projects, and about 180K lines added.", "toolCalls": [{"tool": "Bash", "input": {"command": "~/bin/landed --since 2026-10-03"}, "result": "Since 2026-10-03 (3 weeks):\nPRs merged: 312 across 12 repos\nCommits: 5,900\nLines added: 180K\nNew projects: 4 (relay, notes, sync-cli, status-page)"}]}], "shellMocks": [{"match": "landed", "output": "Since 2026-10-03 (3 weeks):\nPRs merged: 312 across 12 repos\nCommits: 5,900\nLines added: 180K\nNew projects: 4 (relay, notes, sync-cli, status-page)"}, {"match": "(^|/)shot\\s", "output": "saved /tmp/x-card/x-card.png (2400x1350, playwright headless)"}], "world": {"files": {"/tmp/x-card/x-card.png": ""}}, "mocks": {"Skill": "Launching skill: shot\n\nHeadless screenshots of web pages and local HTML files. Run `shot --size WxH --out `. It never opens a visible browser."}, "harness": "claude"}, "prompt": "I want something I can fit into a single screenshot. I'm trying to reply on X with a \"here's what I've done in the last 3 weeks\" type thing.", "expect": {"notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_click", "preview_type", "preview_press", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["\\.png"]}, "notes": "Numbers are fake. Catches a rewrite that pushes every screenshot through the T3 preview. The real thread also ran an unasked Workflow; that is not graded here.", "maxSteps": 10} +{"id": "guard-open-preview-for-user-sign-in", "area": "visual-preview", "kind": "guard", "pattern": "'open it in T3 preview, I'll sign in' -> preview_open, then stop", "experience": "The user asked for a page in the T3 preview so he could sign in. The agent opened it and waited.", "rule": "preview_open 'open its thread-bound inline preview by default'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Restart the database container on the storage box."}, {"role": "assistant", "text": "The restart needs the admin panel at http://admin.acme.test:5000, and its session expired. Someone has to sign in again."}], "mocks": {"t3_preview_list": {"sessions": [], "nextCursor": null}, "preview_status": {"available": false, "visible": false, "tabId": null, "url": null, "title": null, "loading": false, "profiles": [{"id": "default", "name": "Default"}, {"id": "incognito", "name": "Incognito", "incognito": true}], "defaultProfileId": "default"}, "preview_open": {"available": true, "visible": false, "tabId": "tab_1", "url": "http://admin.acme.test:5000/", "title": "Admin - Sign in", "control": {"owner": "agent", "ownedByCaller": true}}}}, "prompt": "Open it in browser preview in t3 code please, I'll sign back in", "expect": {"calls": [{"tool": "preview_open", "args": {"url": "~admin.acme.test"}}], "notCalls": ["t3_preview_close", "preview_act"]}, "notes": "Opposite of preview-tab-not-closed-after-own-check: this tab belongs to the user's task and must stay open."} +{"id": "guard-stop-preview-after-correction", "area": "visual-preview", "kind": "guard", "pattern": "after 'stop checking, just ship' -> no preview calls", "experience": "After the user said to stop checking and ship, the agent stopped preview work and deployed.", "rule": "none (user instruction)", "privacy": "commit-safe", "setup": {"role": "top", "context": "Repo: a kanban demo app. `pnpm dev` serves http://localhost:3017. `pnpm run deploy` deploys and prints the live URL.", "priorTurns": [{"role": "user", "text": "Make me a fun kanban demo. Deploy as you make changes and share the link."}, {"role": "assistant", "text": "First deploy is up. I'm adding drag-and-drop and checking it in the preview.", "toolCalls": [{"tool": "Bash", "input": {"command": "pnpm run deploy"}, "result": "✓ built in 1.1s\nDeployed: https://clear-garden.acme.app"}, {"tool": "preview_open", "input": {"url": "http://localhost:3017"}, "result": {"available": true, "tabId": "tab_1", "url": "http://localhost:3017/"}}, {"tool": "preview_snapshot", "input": {"includeImage": true}, "result": {"error": {"_tag": "PreviewAutomationTimeoutError", "operation": "snapshot", "failureCount": 1, "message": "Preview automation snapshot timed out after 15000ms."}}}, {"tool": "preview_act", "input": {"action": "click", "locator": "role=button[name='New quest']"}, "result": [{"type": "text", "text": "Preview automation click timed out after 15000ms."}]}]}], "shellMocks": [{"match": "deploy", "output": "Deployed: https://clear-garden.acme.app"}], "world": {"files": {"package.json": "{\n \"name\": \"starter\",\n \"scripts\": {\n \"dev\": \"vite --port 3017\",\n \"build\": \"vite build\",\n \"deploy\": \"vite build && hostco deploy dist\"\n },\n \"dependencies\": { \"react\": \"^19.2.0\", \"react-dom\": \"^19.2.0\" },\n \"devDependencies\": { \"@vitejs/plugin-react\": \"^5.0.0\", \"hostco\": \"^3.2.0\", \"vite\": \"^7.1.0\" }\n}\n", "src/App.tsx": "import { Board } from './Board';\n\nexport function App() {\n return ;\n}\n", "src/todos.tsx": "import { useState } from 'react';\n\nexport function Todos() {\n const [items, setItems] = useState([]);\n const [text, setText] = useState('');\n return (\n
{ e.preventDefault(); setItems([...items, text]); setText(''); }}>\n setText(e.target.value)} placeholder=\"Add a task\" />\n \n
    {items.map((t) =>
  • {t}
  • )}
\n
\n );\n}\n", "src/styles.css": ":root{color-scheme:dark;--bg:#000;--fg:#fff}\nbody{background:var(--bg);color:var(--fg)}\n", "src/Board.tsx": "export function Board({ columns }: { columns: string[] }) {\n return
{columns.map((c) =>

{c}

)}
;\n}\n"}}}, "prompt": "Preview browser is up. Please stop trying to check so much, just ship. Deploy", "expect": {"notCalls": ["preview_status", "preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["https://clear-garden\\.acme\\.app"]}, "notes": "Guard on the recovery after demo-build-opens-preview-unasked.", "maxSteps": 8} +{"id": "guard-x-card-png-uses-shot", "area": "visual-preview", "kind": "guard", "pattern": "headless screenshot ask -> shot skill or a shell browser, not the preview", "experience": "The user wanted one image to post on X. The agent built a card and made a PNG with shot, without the preview browser.", "rule": "The user's CLAUDE.md: 'Prefer T3 preview tools for its browser ... and shot for headless web screenshots and videos'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Go through what I landed in the last 3 weeks and summarize it."}, {"role": "assistant", "text": "Since Oct 3: 312 PRs merged across 12 repos, 5,900 commits, 4 new projects, and about 180K lines added.", "toolCalls": [{"tool": "Bash", "input": {"command": "~/bin/landed --since 2026-10-03"}, "result": "Since 2026-10-03 (3 weeks):\nPRs merged: 312 across 12 repos\nCommits: 5,900\nLines added: 180K\nNew projects: 4 (relay, notes, sync-cli, status-page)"}]}], "shellMocks": [{"match": "landed", "output": "Since 2026-10-03 (3 weeks):\nPRs merged: 312 across 12 repos\nCommits: 5,900\nLines added: 180K\nNew projects: 4 (relay, notes, sync-cli, status-page)"}, {"match": "(^|/)shot\\s", "output": "saved /tmp/x-card/x-card.png (2400x1350, playwright headless)"}], "world": {"files": {"/tmp/x-card/x-card.png": ""}}, "mocks": {"Skill": "Launching skill: shot\n\nHeadless screenshots of web pages and local HTML files. Run `shot --size WxH --out `. It never opens a visible browser."}, "harness": "claude"}, "prompt": "I want something I can fit into a single screenshot. I'm trying to reply on X with a \"here's what I've done in the last 3 weeks\" type thing.", "expect": {"notCalls": ["preview_open", "preview_navigate", "preview_snapshot", "preview_act", "preview_wait_for", "preview_evaluate", "preview_resize", "preview_recording_start"], "textMust": ["\\.png"]}, "notes": "Numbers are fake. Catches a rewrite that pushes every screenshot through the T3 preview. The real thread also ran an unasked Workflow; that is not graded here.", "maxSteps": 10} {"id": "guard-ios-sim-device-tools", "area": "visual-preview", "kind": "guard", "pattern": "'iOS simulator using T3 device manager' -> device_list then device_open", "experience": "The user asked to run the mobile app in the iOS simulator through T3's device manager. The agent listed devices and opened one.", "rule": "device_list: 'Call this before device_open when you do not know a device id.'", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"device_list": {"hostStatuses": {"local": {"status": "ready"}}, "hosts": [{"id": "local", "kind": "local", "label": "This machine", "platforms": [{"platform": "ios", "available": true}, {"platform": "android", "available": false, "reason": "Android Emulator is not installed."}], "devices": [{"id": "4B6971DC-ADA7-4A7A-A152-71A6EAF4D4F9", "platform": "ios", "name": "iPhone 17", "version": "iOS 26.5", "booted": false, "physical": false}]}], "openDevices": []}, "device_open": {"device": {"hostId": "local", "id": "4B6971DC-ADA7-4A7A-A152-71A6EAF4D4F9", "platform": "ios", "name": "iPhone 17", "version": "iOS 26.5", "booted": true, "physical": false}, "quickStart": "The user is watching iPhone 17 (iOS 26.5) in the Device panel."}}, "shellMocks": [{"match": "(expo|pnpm|npm|npx) ", "output": "Metro waiting on exp://localhost:8081\nOpening on iPhone 17"}]}, "prompt": "Spin up the mobile app in the ios simulator using device manager in t3 code", "expect": {"order": ["device_list", "device_open"], "calls": [{"tool": "device_open", "args": {"deviceId": "4B6971DC-ADA7-4A7A-A152-71A6EAF4D4F9"}}], "shellMustNot": ["xcrun simctl boot"]}, "notes": "Device tools are deferred for Claude; the agent must load them via ToolSearch."} {"id": "guard-capabilities-recheck-after-provider-returns", "area": "visual-preview", "kind": "guard", "pattern": "re-call orchestrator_capabilities when the catalog really changed", "experience": "A provider was disabled earlier. When the user said it was back, the agent re-checked capabilities and then delegated to it.", "rule": "'Use orchestrator_capabilities to discover the current provider/model IDs'", "privacy": "commit-safe", "setup": {"role": "top", "priorTurns": [{"role": "user", "text": "Make a smaller, clearer PR that fixes the hidden-tab bug. Have Gemini 3 Pro write the PR description, you tend to be too wordy."}, {"role": "assistant", "text": "The fix is pushed to branch fix/hidden-tab. Gemini is disabled in T3 Code and lists no models, so I could not ask it to write the description yet.", "toolCalls": [{"tool": "Edit", "input": {"file_path": "/home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/tabs.ts", "old_string": " focus(tab);", "new_string": " if (!tab.hidden) focus(tab);"}, "result": "The file /home/user/.t3/worktrees/t3code/t3-f8cdd940/apps/web/src/tabs.ts has been updated successfully."}, {"tool": "Bash", "input": {"command": "git add -A && git commit -m \"fix(web): hidden tabs no longer steal focus\""}, "result": "[fix/hidden-tab 4d2c1b0] fix(web): hidden tabs no longer steal focus\n 1 file changed"}, {"tool": "Bash", "input": {"command": "git push -u origin fix/hidden-tab"}, "result": "To github.com:acme/app.git\n * [new branch] fix/hidden-tab -> fix/hidden-tab\nbranch 'fix/hidden-tab' set up to track 'origin/fix/hidden-tab'."}, {"tool": "orchestrator_capabilities", "input": {}, "result": {"parentThreadId": "11111111-1111-4111-8111-111111111111", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [], "runtimeMode": "full-access", "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Opus 5.5"}, {"id": "claude-sonnet-5-5", "label": "Sonnet 5.5"}]}, {"providerInstanceId": "gemini", "driverKind": "gemini", "displayName": "Gemini", "enabled": false, "models": []}]}}]}], "mocks": {"orchestrator_capabilities": {"parentThreadId": "11111111-1111-4111-8111-111111111111", "inheritedProviderInstanceId": "claudeAgent", "inheritedModel": "claude-sonnet-5-5", "inheritedOptions": [], "runtimeMode": "full-access", "providers": [{"providerInstanceId": "claudeAgent", "driverKind": "claudeAgent", "displayName": "Claude", "models": [{"id": "claude-opus-5-5", "label": "Opus 5.5"}, {"id": "claude-sonnet-5-5", "label": "Sonnet 5.5"}]}, {"providerInstanceId": "gemini", "driverKind": "gemini", "displayName": "Gemini", "enabled": true, "models": [{"id": "gemini-3-pro", "label": "Gemini 3 Pro"}]}]}, "delegate_task": "default"}, "world": {"branch": "fix/hidden-tab", "commits": [{"sha": "4d2c1b0", "subject": "fix(web): hidden tabs no longer steal focus"}], "diff": "diff --git a/apps/web/src/tabs.ts b/apps/web/src/tabs.ts\n--- a/apps/web/src/tabs.ts\n+++ b/apps/web/src/tabs.ts\n@@ -10,1 +10,1 @@\n- focus(tab);\n+ if (!tab.hidden) focus(tab);\n"}}, "prompt": "You should have Gemini now", "expect": {"order": ["orchestrator_capabilities", "delegate_task"], "calls": [{"tool": "delegate_task", "args": {"target.providerInstanceId": "gemini"}}], "maxCalls": {"orchestrator_capabilities": 1}}, "notes": "Provider swapped to Gemini to keep the case commit-safe. Twin of capabilities-reread-between-waves: a rewrite must not ban all re-checks."} {"id": "guard-inline-visual-preview-then-render", "area": "visual-preview", "kind": "guard", "pattern": "inline visual ask -> html_preview then html_render", "experience": "The user asked for an inline visualization of a small table. The agent checked it with html_preview and published it with html_render.", "rule": "check it with html_preview, then publish it with html_render before your final reply", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"html_preview": {"width": 728, "contentHeight": 560, "capturedHeight": 560, "consoleMessages": []}, "html_render": {"htmlRender": {"attachmentId": "att-1", "title": "Failed implementations", "height": 560}, "message": "Shown to the reader above your reply. Don't mention or describe the page; reply with only what it doesn't already say."}}}, "prompt": "Make a nice visualization in line here for this data showcasing how many bad implementations have been made and how expensive they were\n\nFeature | attempts | failed attempts | cost of failed attempts\nSidebar drag and drop | 6 | 4 | $310\nCommand+F search | 3 | 1 | $45\nPR watch | 5 | 3 | $220\nInline HTML frames | 4 | 2 | $160\nDevice panel | 2 | 0 | $0", "expect": {"order": ["html_preview", "html_render"], "notCalls": ["preview_open"], "callsMustNot": [{"tool": "html_render", "args": {"html": "~background:#000"}}, {"tool": "html_render", "args": {"html": "~background: #000"}}, {"tool": "html_render", "args": {"html": "~background-color:#000"}}, {"tool": "html_render", "args": {"html": "~background-color: #000"}}, {"tool": "html_render", "args": {"html": "~background:black"}}, {"tool": "html_render", "args": {"html": "~background: black"}}, {"tool": "html_render", "args": {"html": "~background-color:black"}}, {"tool": "html_render", "args": {"html": "~background-color: black"}}, {"tool": "html_render", "args": {"html": "~color:#fff"}}, {"tool": "html_render", "args": {"html": "~color: #fff"}}, {"tool": "html_render", "args": {"html": "~color:white"}}, {"tool": "html_render", "args": {"html": "~color: white"}}]}, "notes": "Data is fake."} @@ -23,6 +23,6 @@ {"id": "control-explain-why-change", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: explain a change from git history", "experience": "Explain why a commit was made. No T3 tool is needed.", "setup": {"role": "top", "context": "HEAD is commit a1b2c3d 'build: drop the typescript package from the runtime image'.", "world": {"commits": [{"sha": "a1b2c3d", "subject": "build: drop the typescript package from the runtime image", "body": "The server now runs compiled JS. tsc was only used at build time and added 23 MB to the image."}], "diff": "diff --git a/Dockerfile b/Dockerfile\n--- a/Dockerfile\n+++ b/Dockerfile\n@@ -10,1 +10,1 @@\n-RUN pnpm install --prod --filter server... typescript\n+RUN pnpm install --prod --filter server...\n"}}, "prompt": "Why was this change made?"} {"id": "control-run-lint-and-fix", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task: run lint and fix", "experience": "Run lint and fix the findings. No T3 tool is needed.", "setup": {"role": "top", "shellMocks": [{"match": "lint", "output": "src/util.ts\n 12:7 error 'unused' is assigned a value but never used no-unused-vars\n 30:1 error Missing return type on function explicit-function-return-type\n2 problems"}], "world": {"files": {"src/util.ts": "export function sum(xs: number[]) {\n const unused = 0;\n return xs.reduce((a, b) => a + b, 0);\n}\n\nexport function label(n: number) {\n return `${n} items`;\n}\n"}}}, "prompt": "Run lint and fix whatever it finds.", "notes": "Prompt is generic; the source only shows lint as ordinary work."} {"id": "control-refactor-chart-component", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task"]}, "pattern": "ordinary task with a visual-sounding word ('chart')", "experience": "Refactor a chart component. The word 'chart' must not pull the agent into html_render.", "setup": {"role": "top", "world": {"files": {"src/components/UsageChart.tsx": "import { useState } from 'react';\nimport { nearest, type Row } from '../lib/chart';\nimport { Tooltip } from './Tooltip';\n\ntype Props = { rows: Row[] };\n\nexport function UsageChart({ rows }: Props) {\n const [hover, setHover] = useState(null)\n const onMove = (e: MouseEvent) => { /* find nearest row */ setHover(nearest(rows, e.clientX)) }\n return {/* bars */}{hover && }\n}\n", "src/lib/chart.ts": "export type Row = { day: string; tokens: number };\nexport const nearest = (rows: Row[], x: number) => rows[Math.round(x)] ?? null;\n"}}}, "prompt": "Refactor src/components/UsageChart.tsx so the tooltip hover logic lives in its own hook. No behavior change.", "notes": "Prompt adapted. Catches a rewrite that makes any chart mention trigger html_render."} -{"id": "control-explain-preview-manager-code", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task", "preview_status", "t3_preview_list", "t3_preview_close"]}, "pattern": "ordinary task with a T3-sounding word ('preview tab')", "experience": "Explain code about preview tabs. The words 'preview tab' must not pull the agent into preview tools.", "setup": {"role": "top", "world": {"files": {"apps/server/src/preview/Manager.ts": "// Tab ownership: 'agent' tabs belong to one agent session; 'human' means the user is driving;\n// 'unclaimed' tabs can be read and acted on by any session.\nexport function canAct(tab: Tab, caller: SessionId) {\n if (tab.owner === 'human') return false\n if (tab.owner === 'unclaimed') return true\n return tab.ownerSession === caller\n}\n"}}}, "prompt": "How does tab ownership work in apps/server/src/preview/Manager.ts? Explain it briefly.", "notes": "Prompt adapted; the source is an ordinary explain-code turn."} +{"id": "control-explain-preview-manager-code", "area": "visual-preview", "kind": "control", "privacy": "commit-safe", "rule": "'Ordinary coding tasks need no T3 tools'", "expect": {"notCalls": ["delegate_task", "orchestrator_capabilities", "task_status", "t3_thread_launch", "create_threads", "t3_thread_read", "link_pull_request", "list_thread_pull_requests", "watch_pull_request", "html_preview", "html_render", "preview_status", "preview_open", "preview_snapshot", "t3_preview_list", "device_list", "schedule_task", "t3_preview_close"]}, "pattern": "ordinary task with a T3-sounding word ('preview tab')", "experience": "Explain code about preview tabs. The words 'preview tab' must not pull the agent into preview tools.", "setup": {"role": "top", "world": {"files": {"apps/server/src/preview/Manager.ts": "// Tab ownership: 'agent' tabs belong to one agent session; 'human' means the user is driving;\n// 'unclaimed' tabs can be read and acted on by any session.\nexport function canAct(tab: Tab, caller: SessionId) {\n if (tab.owner === 'human') return false\n if (tab.owner === 'unclaimed') return true\n return tab.ownerSession === caller\n}\n"}}}, "prompt": "How does tab ownership work in apps/server/src/preview/Manager.ts? Explain it briefly.", "notes": "Prompt adapted; the source is an ordinary explain-code turn."} {"id": "proactive-visual-latency-regression", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user pasted a latency table across releases and asked where it regressed. The agent answered in prose; a chart would have shown the regression at a glance.", "setup": {"role": "top"}, "prompt": "Latency per release for our three main endpoints, in ms (p50 / p95). Which release regressed, and on which endpoint?\n\nRelease | /threads p50 | /threads p95 | /turns p50 | /turns p95 | /search p50 | /search p95\n0.41 | 38 | 92 | 120 | 310 | 44 | 101\n0.42 | 37 | 95 | 118 | 305 | 46 | 104\n0.43 | 39 | 97 | 121 | 318 | 45 | 99\n0.44 | 41 | 210 | 119 | 312 | 47 | 102\n0.45 | 40 | 236 | 162 | 480 | 46 | 100\n0.46 | 42 | 241 | 165 | 492 | 48 | 103", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} {"id": "proactive-visual-ci-time-breakdown", "area": "visual-preview", "kind": "bad", "privacy": "commit-safe", "pattern": "data that reads better as a chart answered in prose only", "rule": "When a chart, diagram, or mockup says more than prose: html_preview, then html_render", "source": {"report": "agents used inline HTML visuals less often after #24850 made the rule ask-only"}, "experience": "The user shared CI job times over several runs and asked what eats the time. The agent answered in prose; a stacked chart would have shown it.", "setup": {"role": "top"}, "prompt": "CI got slow. Here are job times in minutes for the last 8 runs on main. What's eating the time?\n\nRun | install | typecheck | lint | test-server | test-web | build | e2e\n1 | 2.1 | 3.0 | 1.2 | 6.8 | 4.1 | 3.3 | 7.9\n2 | 2.0 | 3.1 | 1.1 | 6.9 | 4.0 | 3.4 | 8.2\n3 | 2.2 | 3.0 | 1.2 | 7.4 | 4.2 | 3.3 | 8.0\n4 | 2.1 | 3.2 | 1.3 | 9.8 | 4.1 | 3.5 | 8.1\n5 | 2.3 | 3.1 | 1.2 | 12.6 | 4.3 | 3.4 | 8.4\n6 | 2.2 | 3.3 | 1.2 | 13.1 | 4.2 | 3.6 | 12.9\n7 | 2.1 | 3.2 | 1.3 | 13.4 | 4.4 | 3.5 | 13.2\n8 | 2.2 | 3.2 | 1.2 | 13.0 | 4.3 | 3.4 | 13.5", "expect": {"calls": [{"tool": "html_render"}], "order": ["html_preview", "html_render"], "notCalls": ["preview_open", "preview_navigate"]}} diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index b35877658e271..cca050021dd4a 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -28,6 +28,7 @@ import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http"; import { taskPrompt } from "../../apps/server/src/mcp/OrchestratorMcpService.ts"; import * as McpInvocationContext from "../../apps/server/src/mcp/McpInvocationContext.ts"; +import { advertisedJsonSchema } from "../../apps/server/src/mcp/advertisedJsonSchema.ts"; import { ORCHESTRATOR_MCP_RUNNING_TASK_NEXT } from "@t3tools/contracts"; import { formatThreadLink } from "@t3tools/shared/threadLinks"; import { buildCodexAdditionalContext } from "../../apps/server/src/provider/CodexDeveloperInstructions.ts"; @@ -39,7 +40,10 @@ import { OrchestratorToolkit } from "../../apps/server/src/mcp/toolkits/orchestr import { PreviewToolkit } from "../../apps/server/src/mcp/toolkits/preview/tools.ts"; import { PreviewControlsToolkit } from "../../apps/server/src/mcp/toolkits/previewControls/tools.ts"; import { ProjectToolkit } from "../../apps/server/src/mcp/toolkits/project/tools.ts"; -import { PullRequestsToolkit } from "../../apps/server/src/mcp/toolkits/pullRequests/tools.ts"; +import { + CHILD_PULL_REQUEST_REPORT_NEXT, + PullRequestsToolkit, +} from "../../apps/server/src/mcp/toolkits/pullRequests/tools.ts"; import { ThreadToolkit } from "../../apps/server/src/mcp/toolkits/thread/tools.ts"; import { WorktreeToolkit } from "../../apps/server/src/mcp/toolkits/worktree/tools.ts"; import { @@ -301,7 +305,7 @@ const t3SourceTools: ReadonlyArray = [ const t3Tools: ReadonlyArray = t3SourceTools.map((tool) => ({ name: `${T3_PREFIX}${tool.name}`, description: Tool.getDescription(tool) ?? "", - input_schema: Tool.getJsonSchema(tool), + input_schema: advertisedJsonSchema(tool), })); // T3 does not offer these to a delegated child, so its harness never lists them. const hiddenFromSubagents = new Set( @@ -869,7 +873,7 @@ const t3Default = (sample: Sample, name: string, input: Readonly { +const withProductFields = (name: string, value: unknown, child: boolean): unknown => { if (!isRecord(value)) return value; const linked = (thread: unknown, idKey = "threadId") => { if (!isRecord(thread) || thread.link !== undefined) return thread; @@ -896,6 +900,8 @@ const withProductFields = (name: string, value: unknown): unknown => { return listed("threads"); case "t3_thread_search": return listed("matches"); + case "link_pull_request": + return child ? { ...value, next: CHILD_PULL_REQUEST_REPORT_NEXT } : value; default: return value; } @@ -1084,7 +1090,7 @@ const answerToolCall = Effect.fn("evaluateMcpToolUse.answerToolCall")(function* call.id, name === "orchestrator_capabilities" ? capabilities(sample, mock) - : withProductFields(name, mock), + : withProductFields(name, mock, testCase.setup.role === "delegated-child"), ); if (isT3Name(call.name)) { // T3 refuses a watch from a delegated child. @@ -1096,7 +1102,7 @@ const answerToolCall = Effect.fn("evaluateMcpToolUse.answerToolCall")(function* call.id, name === "orchestrator_capabilities" ? capabilities(sample, value) - : withProductFields(name, value), + : withProductFields(name, value, testCase.setup.role === "delegated-child"), ); } if (call.name === "clock__curr_time") diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 8f70ef5bcbc7e..d78776eb78439 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -18,7 +18,7 @@ Ordinary coding tasks need no T3 tools. - T3 tools may load late. Try the name once before you call it absent.`, `### Visuals and preview -- Show trends, comparisons, and flows as a chart or diagram, even unasked, and any visual the user asks for here: pass its HTML to \`html_preview\`, then \`html_render\`. If the visual is a file, pass that file's HTML. A screenshot on disk or a text description does not show it. Never start a server for it. Tools a web page exposes give data only. Never use them to show results. Asked for an image to post, send, or use elsewhere: a \`.png\` file and its path, never \`html_render\`. +- Answer data with a trend or comparison with a chart, and show any visual the user asks for here: pass its HTML to \`html_preview\`, then \`html_render\`. If the visual is a file, pass that file's HTML. A screenshot on disk or a text description does not show it. Never start a server for it. Tools a web page exposes give data only. Never use them to show results. Asked for an image to post, send, or use elsewhere: a \`.png\` file and its path, never \`html_render\`. - Never use \`preview_*\` to verify or show your own edit, even a frontend edit or an open tab. Do not repeat preview calls from an earlier turn. Use them only when the current request asks for browser work or names a page or tab. Public pages: web fetch or search unless browser work was asked; then preview tools only, no web fetch. Close a tab you opened before you reply, unless the user asked for it or uses it.`, ]; From fe867b86c89eacf876a27a15874e6089ab33fc9d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 22:40:07 -0700 Subject: [PATCH 008/317] fix(server): Claude MCP timeouts preserve the native session (#25660) Co-authored-by: Claude Opus 5.5 --- .../orchestration-v2/ProviderTurnStartService.ts | 4 +++- .../testkit/ProviderSwitch.integration.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 9d9ae2e646cd5..049f8e9a692d4 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -93,12 +93,14 @@ const isProviderTurnStartError = Schema.is(ProviderTurnStartError); /** * Claude refuses these before it reads the prompt: replacing a process running background work, - * or launch arguments whose appended system prompt cannot be read. + * launch arguments whose appended system prompt cannot be read, or MCP registration that never + * answers while the query opens. */ const PROMPT_REFUSAL_TAGS = [ "ClaudeBackgroundWorkBlocksQueryReplacementError", "ClaudeAppendSystemPromptFileError", "ClaudeAppendSystemPromptMissingValueError", + "ClaudeMcpRegistrationTimeoutError", ] as const; const refusedBeforePrompt = (error: unknown): boolean => diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 452509bb97b0d..0fd201750464c 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -36,6 +36,7 @@ import * as SqlClient from "effect/sql/SqlClient"; import * as SqlitePersistence from "../../persistence/Sqlite.ts"; import { CommandPolicyCapabilityUnsupportedError } from "../CommandPolicy.ts"; import { + ClaudeAgentSdkQueryRunnerError, ClaudeAppendSystemPromptFileError, ClaudeBackgroundWorkBlocksQueryReplacementError, ClaudeProviderCapabilitiesV2, @@ -404,6 +405,12 @@ const waitForIdle = Effect.fn("ProviderSwitchTest.waitForIdle")(function* ( return idle; }); +// Same tag as the adapter's unexported error raised when Claude never answers setMcpServers. +class ClaudeMcpRegistrationTimeoutError extends Schema.TaggedError()( + "ClaudeMcpRegistrationTimeoutError", + {}, +) {} + describe("orchestration v2 provider switching", () => { it.live.each([ "compact-native", @@ -1311,6 +1318,13 @@ describe("orchestration v2 provider switching", () => { it.live.each([ ["background work", new ClaudeBackgroundWorkBlocksQueryReplacementError()], ["an unreadable append file", new ClaudeAppendSystemPromptFileError({ path: "missing.md" })], + [ + "an MCP registration timeout", + new ClaudeAgentSdkQueryRunnerError({ + method: "setMcpServers", + cause: new ClaudeMcpRegistrationTimeoutError(), + }), + ], ] as const)("keeps the native session after turns refused for %s", ([, refusalCause]) => Effect.scoped( Effect.gen(function* () { From 32bf48f428200efc95295ed109526f7b88d9d68a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 22:40:33 -0700 Subject: [PATCH 009/317] fix(server): preserve settled runs during delegated acknowledgements (#25672) Co-authored-by: Claude Opus 5.5 --- .../DelegatedCompletionDelivery.test.ts | 98 +++++++++++++++++++ apps/server/src/orchestration-v2/EventSink.ts | 52 ++++++---- .../src/orchestration-v2/Orchestrator.ts | 14 ++- 3 files changed, 141 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts index 3bf563c1f794a..e426eece00654 100644 --- a/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts +++ b/apps/server/src/orchestration-v2/DelegatedCompletionDelivery.test.ts @@ -974,6 +974,104 @@ it.layer(layerTest)("delegated completion delivery repairs", (it) => { ); }), ); + + it.effect.each([ + { before: "running", after: "failed", capture: false }, + { before: "waiting", after: "completed", capture: true }, + ] as const)( + "keeps a $after run that settled between task_status's acknowledge read and commit", + ({ before, after, capture }) => + Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const sink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make(`thread:ack-settle-race-${after}`); + const runId = RunId.make(`run:ack-settle-race-${after}`); + const taskId = NodeId.make(`node:ack-settle-race-${after}-task`); + const checkpointId = CheckpointId.make(`checkpoint:ack-settle-race-${after}`); + yield* seedParentWithTerminalTask({ + threadId, + projectId: ProjectId.make(`project:ack-settle-race-${after}`), + runId, + rootNodeId: NodeId.make(`node:ack-settle-race-${after}-root`), + taskId, + deliveryState: "claimed", + deliveryTaskIds: [taskId], + now, + }); + const parent = (yield* orchestrator.getThreadProjection(threadId)).runs[0]!; + yield* sink.write({ + events: [ + { + id: EventId.make(`event:ack-settle-race-${after}-before`), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { ...parent, status: before }, + }, + ], + }); + const { delegatedCompletion: _cohort, ...settledRun } = parent; + const commit = sink.commitCommand; + let settled = false; + yield* Effect.acquireUseRelease( + Effect.sync(() => + vi.spyOn(sink, "commitCommand").mockImplementation((input) => + Effect.gen(function* () { + if ( + !settled && + input.commandType === "delegated_task.completion-delivery.acknowledge" + ) { + settled = true; + // The provider turn ends (or its checkpoint lands) after acknowledge read the run. + yield* commit({ + commandId: CommandId.make(`command:ack-settle-race-${after}`), + threadId, + commandType: capture ? "checkpoint.capture" : "provider.event", + acceptedAt: now, + effects: [], + events: [ + { + id: EventId.make(`event:ack-settle-race-${after}-settled`), + type: "run.updated", + threadId, + runId, + occurredAt: now, + payload: { + ...settledRun, + status: after, + completedAt: now, + checkpointId: capture ? checkpointId : null, + }, + }, + ], + }); + } + return yield* commit(input); + }), + ), + ), + () => + orchestrator.dispatch({ + type: "delegated_task.completion-delivery.acknowledge", + commandId: CommandId.make(`command:ack-settle-race-${after}:task-status`), + parentThreadId: threadId, + taskId, + observedByRunId: runId, + }), + (spy) => Effect.sync(() => spy.mockRestore()), + ); + assert.isTrue(settled); + const run = (yield* orchestrator.getThreadProjection(threadId)).runs.find( + (candidate) => candidate.id === runId, + )!; + assert.deepEqual( + { status: run.status, completedAt: run.completedAt, checkpointId: run.checkpointId }, + { status: after, completedAt: now, checkpointId: capture ? checkpointId : null }, + ); + }), + ); }); const reconcileCommandId = (name: string) => CommandId.make(`command:runtime-reconcile:${name}`); diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index af8fa5a95af7b..b210d3c9840ae 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -146,6 +146,8 @@ export interface EventSinkV2Shape { readonly effectTypes: ReadonlyArray; readonly reason: string; }; + /** Like writeWithEffects' updateDelegatedCompletionOnly, for this run's run.updated only. */ + readonly updateDelegatedCompletionOnlyRunId?: RunId; }) => Effect.Effect< { readonly receipt: CommandReceiptStore.CommandReceiptV2; @@ -383,6 +385,28 @@ const layerBase: Layer.Layer< return yield* runOutcomes.prepare(storedEvents); }); + // The enclosing write or commit span already traces this per-event projection merge. + const updateDelegatedCompletionOnly = Effect.fnUntraced(function* ( + events: ReadonlyArray, + includesRun: (runId: RunId) => boolean, + ) { + return yield* Effect.forEach(events, (event) => + Effect.gen(function* () { + if (event.type !== "run.updated" || !includesRun(event.payload.id)) return event; + const { runs } = yield* projectionStore.getThreadRecords(event.threadId, ["runs"], { + runIds: [event.payload.id], + }); + const current = runs[0]; + return current === undefined + ? event + : { + ...event, + payload: { ...current, delegatedCompletion: event.payload.delegatedCompletion }, + }; + }), + ); + }); + const writeEffect = Effect.fn("orchestrationV2.EventSink.write")(function* ( input: Parameters[0], ) { @@ -411,26 +435,7 @@ const layerBase: Layer.Layer< // Checkpoint capture can finish after a cohort writer reads its parent run. // Merge in this transaction so the event log and projection keep that completion. const events = input.updateDelegatedCompletionOnly - ? yield* Effect.forEach(input.events, (event) => - Effect.gen(function* () { - if (event.type !== "run.updated") return event; - const { runs } = yield* projectionStore.getThreadRecords( - event.threadId, - ["runs"], - { runIds: [event.payload.id] }, - ); - const current = runs[0]; - return current === undefined - ? event - : { - ...event, - payload: { - ...current, - delegatedCompletion: event.payload.delegatedCompletion, - }, - }; - }), - ) + ? yield* updateDelegatedCompletionOnly(input.events, () => true) : input.events; const normalized = yield* normalizeEvents( input.guardPendingUserInputCancellations === true @@ -646,7 +651,12 @@ const layerBase: Layer.Layer< return { ...existing, committed: false as const, cancelledEffectIds: [] }; } - const normalized = yield* normalizeEvents(input.events); + const runId = input.updateDelegatedCompletionOnlyRunId; + const normalized = yield* normalizeEvents( + runId === undefined + ? input.events + : yield* updateDelegatedCompletionOnly(input.events, (id) => id === runId), + ); const storedEvents = yield* eventStore.append({ commandId: input.commandId, events: normalized, diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 0c6ca268f1e04..ee8ab83b952aa 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -2358,7 +2358,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio run: deliveryRun, now, }); - return; + return parentRun.id; } const message = completionDeliveryMessage(projection, delivery); if (message !== undefined) { @@ -2382,6 +2382,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }); } } + return parentRun.id; }); const disposeDelegatedCompletionCohort = Effect.fn( @@ -11752,6 +11753,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly effectTypes: ReadonlyArray; readonly reason: string; }; + readonly updateDelegatedCompletionOnlyRunId?: RunId; }, OrchestratorV2Error > { @@ -11769,6 +11771,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio readonly reason: string; } | undefined; + let updateDelegatedCompletionOnlyRunId: RunId | undefined; switch (command.type) { case "thread.create": yield* dispatchThreadCreate(command, events); @@ -12080,7 +12083,8 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio break; case "delegated_task.completion-delivery.acknowledge": case "delegated_task.completion-delivery.dispose": - yield* dispatchDelegatedTaskCompletionDeliveryResolution(command, events); + updateDelegatedCompletionOnlyRunId = + yield* dispatchDelegatedTaskCompletionDeliveryResolution(command, events); break; case "thread.created.record": yield* dispatchCreatedThreadRecord(command, events); @@ -12095,6 +12099,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio events: yield* Ref.get(events), effects: yield* Ref.get(effects), ...(cancelUnsettledEffects === undefined ? {} : { cancelUnsettledEffects }), + ...(updateDelegatedCompletionOnlyRunId === undefined + ? {} + : { updateDelegatedCompletionOnlyRunId }), }; }); @@ -12271,6 +12278,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ...(plan.cancelUnsettledEffects === undefined ? {} : { cancelUnsettledEffects: plan.cancelUnsettledEffects }), + ...(plan.updateDelegatedCompletionOnlyRunId === undefined + ? {} + : { updateDelegatedCompletionOnlyRunId: plan.updateDelegatedCompletionOnlyRunId }), }) .pipe( Effect.mapError( From 5522180329c70c7050852ae0d948400d845f55da Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 26 Oct 2026 22:48:21 -0700 Subject: [PATCH 010/317] fix(mobile): align the iPad composer with workspace navigation (#24942) Co-authored-by: maria-rcks --- .../src/features/threads/ThreadComposer.tsx | 27 ++++++++++--------- .../features/threads/ThreadDetailScreen.tsx | 9 +++++-- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 5f910dfc929f5..df5aca19dec1a 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -562,7 +562,8 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const isVoiceInputPresented = voicePresentation.statusLabel !== null; const isExpanded = isFocused || settingsSheetPresentation.keepsComposerExpanded; const { setComposerEditing } = useAdaptiveWorkspaceLayout(); - const hasSidebarTabBar = useSidebarWorkspaceTabs() === "picker"; + const usesLargeControls = + useSidebarWorkspaceTabs() === "picker" || (Platform.OS === "ios" && Platform.isPad); const [compactRowWidth, setCompactRowWidth] = useState(0); const compactAttachmentLimit = compactRowWidth >= 500 ? 3 : 1; const compactEditor = useCompactComposerEditor(bodyText, draftMessage); @@ -768,7 +769,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps const modelControl = ( {isExpanded ? editingAttachments : null} @@ -871,7 +872,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps hidden={showsCompactDictation} > {modelControl} {showStopAction ? ( ) : showResumeAction ? ( ) : ( @@ -1134,7 +1135,7 @@ export const ThreadComposer = function ThreadComposer(props: ThreadComposerProps ) : ( Date: Mon, 26 Oct 2026 22:52:52 -0700 Subject: [PATCH 011/317] chore(evals): store eval cases as .ndjson so review tools skip them (#25782) Co-authored-by: Claude Opus 5.5 --- evals/mcp-tool-use/README.md | 2 +- .../cases/{orchestration.jsonl => orchestration.ndjson} | 0 .../cases/{pull-requests.jsonl => pull-requests.ndjson} | 0 .../cases/{visual-preview.jsonl => visual-preview.ndjson} | 0 evals/mcp-tool-use/run.ts | 8 ++++---- 5 files changed, 5 insertions(+), 5 deletions(-) rename evals/mcp-tool-use/cases/{orchestration.jsonl => orchestration.ndjson} (100%) rename evals/mcp-tool-use/cases/{pull-requests.jsonl => pull-requests.ndjson} (100%) rename evals/mcp-tool-use/cases/{visual-preview.jsonl => visual-preview.ndjson} (100%) diff --git a/evals/mcp-tool-use/README.md b/evals/mcp-tool-use/README.md index bdddd8e96485a..e478b1fec1e5f 100644 --- a/evals/mcp-tool-use/README.md +++ b/evals/mcp-tool-use/README.md @@ -14,7 +14,7 @@ pnpm mcp-tool-use --model claude-opus-5-5 --out runs/opus - `ANTHROPIC_API_KEY` in the repo root `.env` pays for Claude models and the Haiku shell simulator. - Codex models (`gpt-*` in `MODELS` in `run.ts`) go through a CLIProxyAPI server, set up as the `vibeproxy` model provider in `~/.codex/config.toml`. Keep the proxy under about 12 samples in flight across all runs. -- Flags: `--case `, `--cases `, `--samples ` (default 2, and a 3rd when the first 2 disagree), `--concurrency ` (samples in flight, default 8), `--max-steps `, `--effort `, `--dry-run` (prints request bodies, no model calls), and `--context` (prints the size of T3's text and schemas, no key needed). +- Flags: `--case `, `--cases `, `--samples ` (default 2, and a 3rd when the first 2 disagree), `--concurrency ` (samples in flight, default 8), `--max-steps `, `--effort `, `--dry-run` (prints request bodies, no model calls), and `--context` (prints the size of T3's text and schemas, no key needed). - `--baseline --stop-on-regression` stops as soon as a case that passed every baseline sample fails. Use it to test one copy change against a baseline run. - Cost: a full Sonnet run is about $9, and a full Opus run about $17. Codex models through the proxy cost only the shell simulator. diff --git a/evals/mcp-tool-use/cases/orchestration.jsonl b/evals/mcp-tool-use/cases/orchestration.ndjson similarity index 100% rename from evals/mcp-tool-use/cases/orchestration.jsonl rename to evals/mcp-tool-use/cases/orchestration.ndjson diff --git a/evals/mcp-tool-use/cases/pull-requests.jsonl b/evals/mcp-tool-use/cases/pull-requests.ndjson similarity index 100% rename from evals/mcp-tool-use/cases/pull-requests.jsonl rename to evals/mcp-tool-use/cases/pull-requests.ndjson diff --git a/evals/mcp-tool-use/cases/visual-preview.jsonl b/evals/mcp-tool-use/cases/visual-preview.ndjson similarity index 100% rename from evals/mcp-tool-use/cases/visual-preview.jsonl rename to evals/mcp-tool-use/cases/visual-preview.ndjson diff --git a/evals/mcp-tool-use/run.ts b/evals/mcp-tool-use/run.ts index cca050021dd4a..f65ef239ed7d6 100644 --- a/evals/mcp-tool-use/run.ts +++ b/evals/mcp-tool-use/run.ts @@ -1678,7 +1678,7 @@ const main = Effect.gen(function* () { } const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const readJsonl = (file: string, decode: (line: string) => Effect.Effect) => + const readNdjson = (file: string, decode: (line: string) => Effect.Effect) => fs.readFileString(file).pipe( Effect.map((content) => content.split("\n").filter((line) => line.trim() !== "")), Effect.flatMap((lines) => Effect.forEach(lines, (line) => decode(line))), @@ -1687,10 +1687,10 @@ const main = Effect.gen(function* () { const caseFiles = flags.cases ?? (yield* fs.readDirectory(casesDir)) - .filter((name) => name.endsWith(".jsonl")) + .filter((name) => name.endsWith(".ndjson")) .toSorted() .map((name) => path.join(casesDir, name)); - const cases = (yield* Effect.forEach(caseFiles, (file) => readJsonl(file, decodeCase))).flat(); + const cases = (yield* Effect.forEach(caseFiles, (file) => readNdjson(file, decodeCase))).flat(); const wanted = flags.case?.flatMap((ids) => ids.split(",")); const codex = isCodex(flags.model); const harness = codex ? "codex" : "claude"; @@ -1764,7 +1764,7 @@ const main = Effect.gen(function* () { shellModel: flags["shell-model"], }; const previous = - flags.baseline === undefined ? [] : yield* readJsonl(flags.baseline, decodeBaselineRow); + flags.baseline === undefined ? [] : yield* readNdjson(flags.baseline, decodeBaselineRow); const baseline = new Map(groups(previous, (row) => row.id).map(([id, r]) => [id, passRate(r)])); // Only a case that passed every baseline sample can stop a run; flaky ones are just reported. const passedBefore = (id: string) => baseline.get(id) === 1; From 394929af7687886084234ccb92b744f51ede271b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 22:58:53 -0700 Subject: [PATCH 012/317] test(server): CI fails when agent context grows past main (#25785) Co-authored-by: Claude Opus 5.5 --- .github/workflows/agent-context-budget.yml | 55 ++++++++ apps/server/src/mcp/McpHttpServer.ts | 7 +- .../src/provider/AgentContextBudget.test.ts | 127 ++++++++++++++++++ .../src/provider/agentContextBudget.json | 5 + 4 files changed, 192 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/agent-context-budget.yml create mode 100644 apps/server/src/provider/AgentContextBudget.test.ts create mode 100644 apps/server/src/provider/agentContextBudget.json diff --git a/.github/workflows/agent-context-budget.yml b/.github/workflows/agent-context-budget.yml new file mode 100644 index 0000000000000..9396d8e3e7a63 --- /dev/null +++ b/.github/workflows/agent-context-budget.yml @@ -0,0 +1,55 @@ +name: Agent Context Budget + +# Fails when a PR raises a value in agentContextBudget.json above main's. AgentContextBudget.test.ts +# keeps that file equal to the measured context, so the context cannot grow by editing the file. +# To accept growth on purpose, a maintainer adds the agent-context-increase label. +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: agent-context-budget-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + budget: + name: Agent context budget + runs-on: ubuntu-24.04 + timeout-minutes: 5 + env: + BUDGET: apps/server/src/provider/agentContextBudget.json + ALLOW_INCREASE: ${{ contains(github.event.pull_request.labels.*.name, 'agent-context-increase') }} + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + sparse-checkout: apps/server/src/provider/agentContextBudget.json + sparse-checkout-cone-mode: false + + - name: Compare with main + run: | + git fetch --quiet --depth=1 origin main + if ! git cat-file -e "FETCH_HEAD:$BUDGET" 2>/dev/null; then + echo "main has no agent context budget yet." + exit 0 + fi + git show "FETCH_HEAD:$BUDGET" > "$RUNNER_TEMP/main.json" + grown=$(jq -r --slurpfile main "$RUNNER_TEMP/main.json" ' + to_entries[] + | select($main[0][.key] != null and .value > $main[0][.key]) + | "\(.key): \($main[0][.key]) -> \(.value) (+\(.value - $main[0][.key]))" + ' "$BUDGET") + if [ -z "$grown" ]; then + echo "Agent context is not larger than main." + exit 0 + fi + printf 'Agent context grew compared to main:\n%s\n' "$grown" + if [ "$ALLOW_INCREASE" = "true" ]; then + echo "Accepted by the agent-context-increase label." + exit 0 + fi + echo "::error file=$BUDGET::Agent context grew compared to main. Cut the added text, or ask a maintainer to add the agent-context-increase label." + exit 1 diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 179d82dc998c2..044a43baeab1e 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -1055,7 +1055,8 @@ export const layerMcpTransport = McpServer.layerHttp({ allowSessionTermination: true, }).pipe(Layer.provide(layerMcpAuthMiddleware)); -export const layer = Layer.mergeAll( +/** Every tool the T3 MCP server advertises. AgentContextBudget.test.ts measures this set. */ +export const layerTools = Layer.mergeAll( layerPreviewToolkit, layerOrchestratorToolkit, layerThreadToolkit, @@ -1067,4 +1068,6 @@ export const layer = Layer.mergeAll( layerPullRequestsToolkit, layerDeviceToolkit, layerHtmlToolkit, -).pipe(Layer.provideMerge(layerMcpTransport)); +); + +export const layer = layerTools.pipe(Layer.provideMerge(layerMcpTransport)); diff --git a/apps/server/src/provider/AgentContextBudget.test.ts b/apps/server/src/provider/AgentContextBudget.test.ts new file mode 100644 index 0000000000000..09d2fb89ba65a --- /dev/null +++ b/apps/server/src/provider/AgentContextBudget.test.ts @@ -0,0 +1,127 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { ProviderInstanceId } from "@t3tools/contracts"; +import { McpProviderSessions } from "@t3tools/provider-core/server/McpProviderSessions"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import { McpServer } from "effect/ai"; + +import { ServerSecretStore } from "../auth/ServerSecretStore.ts"; +import { ServerConfig } from "../config.ts"; +import { DeviceService } from "../device/DeviceService.ts"; +import { ServerEnvironment } from "../environment/ServerEnvironment.ts"; +import { GitWorkflowService } from "../git/GitWorkflowService.ts"; +import * as McpHttpServer from "../mcp/McpHttpServer.ts"; +import { PreviewAutomationBroker } from "../mcp/PreviewAutomationBroker.ts"; +import { + CLAUDE_PROVIDER, + makeClaudeQueryOptions, +} from "../orchestration-v2/Adapters/ClaudeAdapterV2.ts"; +import { OrchestratorV2 } from "../orchestration-v2/Orchestrator.ts"; +import { ProviderAdapterRegistryV2 } from "../orchestration-v2/ProviderAdapterRegistry.ts"; +import { ThreadLaunchService } from "../orchestration-v2/ThreadLaunchService.ts"; +import { ThreadManagementService } from "../orchestration-v2/ThreadManagementService.ts"; +import { ThreadSearch } from "../orchestration-v2/ThreadSearch.ts"; +import { PreviewManager } from "../preview/Manager.ts"; +import { PreviewBrowser } from "../preview/PreviewBrowser.ts"; +import { ManagedProjectFolders } from "../project/ManagedProjectFolders.ts"; +import { ProjectService } from "../project/ProjectService.ts"; +import { ProjectSetupScriptRunner } from "../project/ProjectSetupScriptRunner.ts"; +import { PullRequestReviewGuideService } from "../pullRequest/PullRequestReviewGuideService.ts"; +import { ScheduledTaskService } from "../scheduledTasks/ScheduledTaskService.ts"; +import { SecretRequests } from "../secrets/SecretRequests.ts"; +import { ServerSettingsService } from "../serverSettings.ts"; +import { SourceControlRepositoryService } from "../sourceControl/SourceControlRepositoryService.ts"; +import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; +import { VcsStatusBroadcaster } from "../vcs/VcsStatusBroadcaster.ts"; +import { buildCodexAdditionalContext } from "./CodexDeveloperInstructions.ts"; +import { DocumentText } from "./DocumentText.ts"; +import { ProviderRegistry } from "./ProviderRegistry.ts"; +import budget from "./agentContextBudget.json" with { type: "json" }; + +// Registration only stores tool definitions, so the handlers' services are never called. +const toolHandlerServices = Layer.mergeAll( + Layer.mock(DeviceService)({}), + Layer.mock(DocumentText)({}), + Layer.mock(GitVcsDriver)({}), + Layer.mock(GitWorkflowService)({}), + Layer.mock(ManagedProjectFolders)({ namedProjectsRoot: "" }), + Layer.mock(McpProviderSessions)({}), + Layer.mock(OrchestratorV2)({}), + Layer.mock(PreviewAutomationBroker)({}), + Layer.mock(PreviewBrowser)({}), + Layer.mock(PreviewManager)({}), + Layer.mock(ProjectService)({}), + Layer.mock(ProjectSetupScriptRunner)({}), + Layer.mock(ProviderAdapterRegistryV2)({}), + Layer.mock(ProviderRegistry)({}), + Layer.mock(PullRequestReviewGuideService)({}), + Layer.mock(ScheduledTaskService)({}), + Layer.mock(SecretRequests)({}), + ServerConfig.layerTest(process.cwd(), { prefix: "t3-agent-context-budget-" }), + Layer.mock(ServerEnvironment)({}), + Layer.mock(ServerSecretStore)({}), + Layer.mock(ServerSettingsService)({}), + Layer.mock(SourceControlRepositoryService)({}), + Layer.mock(ThreadLaunchService)({}), + Layer.mock(ThreadManagementService)({}), + Layer.mock(ThreadSearch)({}), + Layer.mock(VcsStatusBroadcaster)({}), +).pipe(Layer.provideMerge(NodeServices.layer)); + +const utf8Bytes = (text: string) => Buffer.byteLength(text, "utf8"); + +const claudeSystemAppend = () => { + const { systemPrompt } = makeClaudeQueryOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make(CLAUDE_PROVIDER), + model: "claude-opus-5-5", + }, + nativeThreadId: "context-budget", + resume: false, + cwd: null, + mcpServers: { "t3-code": { type: "http", url: "http://127.0.0.1/mcp" } }, + }); + return typeof systemPrompt === "object" && "append" in systemPrompt ? systemPrompt.append : ""; +}; + +const codexAdditionalContext = () => + Object.values( + buildCodexAdditionalContext( + { model: "gpt-6.1-sol", modelName: "GPT-6.1 Sol", reasoningEffort: "high" }, + { browser: true, device: true }, + ), + ) + .map((entry) => entry.value) + .join(""); + +// CI rejects a PR that raises any value above main's agentContextBudget.json, so growth +// needs the agent-context-increase label. Lower the values when the text shrinks. +it.effect("agent context matches the committed budget", () => + Effect.gen(function* () { + const mcp = yield* McpServer.McpServer; + const measured = { + claudeSystemAppendBytes: utf8Bytes(claudeSystemAppend()), + codexAdditionalContextBytes: utf8Bytes(codexAdditionalContext()), + // The model sees these fields. outputSchema and annotations are protocol metadata. + mcpToolListJsonBytes: utf8Bytes( + JSON.stringify( + mcp.tools.map(({ tool }) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), + ), + }; + + expect(measured).toEqual(budget); + }).pipe( + Effect.provide( + McpHttpServer.layerTools.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(toolHandlerServices), + ), + ), + ), +); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json new file mode 100644 index 0000000000000..e3272bfdf8021 --- /dev/null +++ b/apps/server/src/provider/agentContextBudget.json @@ -0,0 +1,5 @@ +{ + "claudeSystemAppendBytes": 4857, + "codexAdditionalContextBytes": 5409, + "mcpToolListJsonBytes": 54964 +} From bea2e9d62fdc2440c2db4d670b8741425b526969 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 23:02:19 -0700 Subject: [PATCH 013/317] refactor(mobile): theme preferences reach the native runtime without a layout effect (#25767) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 2 + .../AppearancePreferencesProvider.tsx | 237 ++++++++++-------- 2 files changed, 139 insertions(+), 100 deletions(-) diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index acb6287e3a357..3ade5d940fdbb 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -24,6 +24,7 @@ import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcase import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider, + installAppearanceRuntime, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; @@ -107,6 +108,7 @@ const appServiceDisposers = [ threadRefsAtom: environmentThreadShells.threadRefsAtom, shellStateAtom: environmentShell.stateAtom, }), + installAppearanceRuntime(), installSubscriptionUsageRefresh(), installPermissionUpdateNotice(), installThreadOutboxDelivery(), diff --git a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx index a7b2807d78226..bdde057dd232f 100644 --- a/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx +++ b/apps/mobile/src/features/settings/appearance/AppearancePreferencesProvider.tsx @@ -1,19 +1,8 @@ -import { - createContext, - startTransition, - use, - useCallback, - useEffect, - useState, - useLayoutEffect, - useMemo, - useRef, - type ReactNode, -} from "react"; +import { createContext, startTransition, use, useCallback, useMemo, type ReactNode } from "react"; import { AppState, Appearance, Platform, useColorScheme } from "react-native"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; -import { AsyncResult } from "effect/reactivity"; +import { Atom, AsyncResult } from "effect/reactivity"; import { ScopedTheme, ScopedVariables, Uniwind } from "uniwind"; @@ -22,9 +11,14 @@ import { resolveAppearancePreferences, type ResolvedAppearance, } from "../../../lib/appearancePreferences"; +import { appAtomRegistry } from "../../../state/atom-registry"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../../state/preferences"; import type { Preferences } from "../../../persistence/mobile-preferences"; -import { isSystemColorsAvailable, readSystemColorPalettes } from "../../../lib/materialYouPalette"; +import { + isSystemColorsAvailable, + readSystemColorPalettes, + type MaterialYouPalettes, +} from "../../../lib/materialYouPalette"; import { materialYouPaletteToMobileThemeVariables } from "../../../lib/materialYouTheme"; import { getMobileThemeRuntimeVariables } from "../../../lib/mobileThemeVariables"; import type { MobileThemeVariables } from "../../../lib/mobileTheme"; @@ -74,6 +68,115 @@ interface AppearancePreferencesContextValue { const AppearancePreferencesContext = createContext(null); +function storedPreferencesOf(result: AsyncResult.AsyncResult) { + return AsyncResult.isSuccess(result) ? result.value : null; +} + +/** + * Material You palettes follow the system wallpaper, which can change while the + * app is in the background. + */ +const systemColorPalettesAtom = Atom.make((get): MaterialYouPalettes | null => { + const initial = readSystemColorPalettes(); + if (!isSystemColorsAvailable) return initial; + let current = initial; + const refresh = () => { + const next = readSystemColorPalettes(); + if (JSON.stringify(current) === JSON.stringify(next)) return; + current = next; + get.setSelf(next); + }; + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") refresh(); + }); + const focusSubscription = AppState.addEventListener("focus", refresh); + get.addFinalizer(() => { + subscription.remove(); + focusSubscription.remove(); + }); + return initial; +}).pipe(Atom.withLabel("mobile:system-color-palettes")); + +function resolveThemeVariablesByAppearance( + themeIds: MobileThemeIds, + palettes: MaterialYouPalettes | null, +): Readonly> { + const resolve = (appearance: MobileThemeAppearance) => { + const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); + return themeIds[appearance] === "material-you" && palettes + ? materialYouPaletteToMobileThemeVariables(palettes[appearance], appearance, base) + : base; + }; + return { light: resolve("light"), dark: resolve("dark") }; +} + +/** The appearance whose theme is on screen, read from the native scheme. */ +function resolveThemeAppearance(stored: Preferences | null): MobileThemeAppearance { + const themeMode = normalizeMobileThemeMode(stored?.themeMode); + if (themeMode !== "system") return themeMode; + return Appearance.getColorScheme() === "dark" ? "dark" : "light"; +} + +function runtimeStateFor(stored: Preferences | null): MobileThemeRuntimeState { + return { + baseFontSize: resolveAppearancePreferences(stored).baseFontSize, + themeMode: normalizeMobileThemeMode(stored?.themeMode), + themeAppearance: resolveThemeAppearance(stored), + }; +} + +const themeRuntimeStateAtom = Atom.make((get) => + runtimeStateFor(storedPreferencesOf(get(mobilePreferencesAtom))), +).pipe( + Atom.withEquality( + (left: MobileThemeRuntimeState, right: MobileThemeRuntimeState) => + left.baseFontSize === right.baseFontSize && + left.themeMode === right.themeMode && + left.themeAppearance === right.themeAppearance, + ), +); + +// Native appearance and Uniwind text variables are process-global, so the +// last applied state is too. Setters apply ahead of the preference write so the +// first frame after a change already uses it; the atom subscription then sees +// no difference. +let appliedRuntimeState: MobileThemeRuntimeState | null = null; + +function applyThemeRuntime(next: MobileThemeRuntimeState) { + for (const operation of createMobileThemeRuntimeOperations(appliedRuntimeState, next)) { + if (operation.kind === "update-text-variables") { + Uniwind.updateCSSVariables(operation.themeName, operation.variables); + continue; + } + if (operation.kind === "set-appearance-mode") { + Appearance.setColorScheme( + operation.themeMode === "system" ? "unspecified" : operation.appearance, + ); + } + } + appliedRuntimeState = next; +} + +function currentRuntimeState() { + return appliedRuntimeState ?? appAtomRegistry.get(themeRuntimeStateAtom); +} + +/** + * App-lifetime: applies theme preferences to the native runtime and holds the + * system palette listeners. Holding them here instead of `keepAlive` lets the + * fast-refresh disposer release the replaced module's AppState listeners. + */ +export function installAppearanceRuntime(): () => void { + const unsubscribeRuntime = appAtomRegistry.subscribe(themeRuntimeStateAtom, applyThemeRuntime, { + immediate: true, + }); + const releasePalettes = appAtomRegistry.mount(systemColorPalettesAtom); + return () => { + unsubscribeRuntime(); + releasePalettes(); + }; +} + export function AppearancePreferencesProvider(props: { readonly children: ReactNode }) { const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); @@ -94,37 +197,11 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN ); const themeId = themeIds[themeAppearance]; const systemColorsActive = themeId === "material-you" && isSystemColorsAvailable; - const [systemColorPalettes, setSystemColorPalettes] = useState(readSystemColorPalettes); - useEffect(() => { - if (!isSystemColorsAvailable) return; - const refresh = () => { - const next = readSystemColorPalettes(); - setSystemColorPalettes((previous) => - JSON.stringify(previous) === JSON.stringify(next) ? previous : next, - ); - }; - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") refresh(); - }); - const focusSubscription = AppState.addEventListener("focus", refresh); - return () => { - subscription.remove(); - focusSubscription.remove(); - }; - }, []); - const themeVariablesByAppearance = useMemo(() => { - const resolve = (appearance: MobileThemeAppearance) => { - const base = getMobileThemeRuntimeVariables(themeIds[appearance], appearance, Platform.OS); - return themeIds[appearance] === "material-you" && systemColorPalettes - ? materialYouPaletteToMobileThemeVariables( - systemColorPalettes[appearance], - appearance, - base, - ) - : base; - }; - return { light: resolve("light"), dark: resolve("dark") }; - }, [themeIds, systemColorPalettes]); + const systemColorPalettes = useAtomValue(systemColorPalettesAtom); + const themeVariablesByAppearance = resolveThemeVariablesByAppearance( + themeIds, + systemColorPalettes, + ); const themeVariables = themeVariablesByAppearance[themeAppearance]; const activeThemeName = getMobileUniwindThemeName(themeId, themeAppearance); const { baseFontSize, codeFontSize, codeWordBreak, terminalFontSize } = preferences; @@ -133,38 +210,6 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [baseFontSize, codeFontSize, codeWordBreak, terminalFontSize], ); const isReady = AsyncResult.isSuccess(preferencesResult); - const runtimeState = useMemo( - () => ({ - baseFontSize, - themeAppearance, - themeMode, - }), - [baseFontSize, themeAppearance, themeMode], - ); - const appliedRuntimeStateRef = useRef(null); - const selectedThemeIdsRef = useRef(themeIds); - - const applyThemeRuntime = useCallback((next: MobileThemeRuntimeState) => { - const operations = createMobileThemeRuntimeOperations(appliedRuntimeStateRef.current, next); - for (const operation of operations) { - if (operation.kind === "update-text-variables") { - Uniwind.updateCSSVariables(operation.themeName, operation.variables); - continue; - } - if (operation.kind === "set-appearance-mode") { - Appearance.setColorScheme( - operation.themeMode === "system" ? "unspecified" : operation.appearance, - ); - } - } - appliedRuntimeStateRef.current = next; - }, []); - - const syncThemeRuntime = useCallback( - (next: MobileThemeRuntimeState) => applyThemeRuntime(next), - [applyThemeRuntime], - ); - const updatePreferences = useCallback( (patch: Partial) => { startTransition(() => savePreferences(patch)); @@ -179,37 +224,30 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN [savePreferences], ); - useLayoutEffect(() => { - selectedThemeIdsRef.current = themeIds; - syncThemeRuntime(runtimeState); - }, [runtimeState, syncThemeRuntime, themeIds]); - const setThemeIdForAppearance = useCallback( (appearance: MobileThemeAppearance, value: MobileThemeId) => { - const patch = createMobileThemeSelectionPatch( - selectedThemeIdsRef.current, - themeAppearance, - appearance, - value, + // Read at call time: a second pick before the next render must build on + // the first one's optimistic write. + const selected = resolveMobileThemeIds( + storedPreferencesOf(appAtomRegistry.get(mobilePreferencesAtom)) ?? {}, + ); + updateThemePreferences( + createMobileThemeSelectionPatch(selected, themeAppearance, appearance, value), ); - selectedThemeIdsRef.current = resolveMobileThemeIds(patch); - updateThemePreferences(patch); }, [themeAppearance, updateThemePreferences], ); const setThemeIdForBothAppearances = useCallback( (value: MobileThemeId) => { - const patch = createMobileThemePairPatch(value); - selectedThemeIdsRef.current = resolveMobileThemeIds(patch); - updateThemePreferences(patch); + updateThemePreferences(createMobileThemePairPatch(value)); }, [updateThemePreferences], ); const setThemeMode = useCallback( (value: MobileThemeMode) => { - const current = appliedRuntimeStateRef.current ?? runtimeState; + const current = currentRuntimeState(); // Clear forced appearance before publishing System so useColorScheme batches the system palette into the preference commit. if (value === "system") { @@ -223,23 +261,22 @@ export function AppearancePreferencesProvider(props: { readonly children: ReactN themeMode: value, }; - updateThemePreferences({ themeMode: value }); if (value === "system") { - appliedRuntimeStateRef.current = next; + appliedRuntimeState = next; } else { - syncThemeRuntime(next); + applyThemeRuntime(next); } + updateThemePreferences({ themeMode: value }); }, - [runtimeState, syncThemeRuntime, updateThemePreferences], + [updateThemePreferences], ); const setBaseFontSize = useCallback( (value: number) => { - const current = appliedRuntimeStateRef.current ?? runtimeState; - syncThemeRuntime({ ...current, baseFontSize: value }); + applyThemeRuntime({ ...currentRuntimeState(), baseFontSize: value }); updatePreferences({ baseFontSize: value }); }, - [runtimeState, syncThemeRuntime, updatePreferences], + [updatePreferences], ); const setTerminalFontSize = useCallback( From 2ad853ab211935a414cdf3c2f5004db635ae04bc Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 23:03:48 -0700 Subject: [PATCH 014/317] refactor(mobile): the share inbox is a module store, presented from navigation state (#25744) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 23 +- apps/mobile/src/Stack.tsx | 31 +-- .../sharing/IncomingShareProvider.tsx | 257 +++++++----------- apps/mobile/src/lib/appState.ts | 8 + apps/mobile/src/lib/coalescedRun.test.ts | 57 ++++ apps/mobile/src/lib/coalescedRun.ts | 26 ++ 6 files changed, 209 insertions(+), 193 deletions(-) create mode 100644 apps/mobile/src/lib/coalescedRun.test.ts create mode 100644 apps/mobile/src/lib/coalescedRun.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 3ade5d940fdbb..579aea37bfa4d 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -21,7 +21,10 @@ import { ThreadArrangementHost } from "./features/threads/ThreadArrangementSheet import * as Dialog from "./components/ui/Dialog"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; -import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; +import { + presentPendingIncomingShare, + startIncomingShareInbox, +} from "./features/sharing/IncomingShareProvider"; import { AppearancePreferencesProvider, installAppearanceRuntime, @@ -92,6 +95,7 @@ const Navigation = createStaticNavigation(RootStack); function handleNavigationStateChange(state: NavigationState | undefined): void { recordWorkspaceSelections(state); if (state) pruneNewTaskBranchCheckouts(state); + presentPendingIncomingShare(); } function handleNavigationReady(): void { @@ -113,6 +117,7 @@ const appServiceDisposers = [ installPermissionUpdateNotice(), installThreadOutboxDelivery(), installComposerAttachmentUploads(), + startIncomingShareInbox(), ]; disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { for (const dispose of appServiceDisposers) dispose(); @@ -155,15 +160,13 @@ function AppContent() { {/* native-stack needs the theme's dark flag to select dark native headers. */} - - - + diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 74a8600f1e3aa..7312807c55e48 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,7 @@ import { createNativeStackScreen, type NativeStackNavigationOptions, } from "@react-navigation/native-stack"; -import { useEffect, useRef, type ReactNode } from "react"; +import type { ReactNode } from "react"; import { Platform, Pressable, ScrollView, StyleSheet, useWindowDimensions } from "react-native"; import { useResolveClassNames } from "uniwind"; @@ -139,20 +139,12 @@ import { SettingsLegalDocumentExternalHeaderButton, } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; -import { useIncomingShare } from "./features/sharing/IncomingShareProvider"; -import { - EMPTY_INCOMING_SHARE_PRESENTATION_STATE, - transitionIncomingSharePresentation, -} from "./features/sharing/incoming-share-presentation"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; import { nativeHeaderScrollEdgeEffects } from "./native/StackHeader"; import { HORIZONTAL_FORM_SHEET_OPTIONS, formSheetDetents } from "./native/sheet-surface"; -import { - selectWorkspaceDetail, - workspaceDetailRouter, -} from "./features/workspace/workspace-detail-selection"; +import { workspaceDetailRouter } from "./features/workspace/workspace-detail-selection"; const HEADER_SCROLL_EDGE_EFFECTS = nativeHeaderScrollEdgeEffects(Platform.OS, Platform.Version); @@ -498,9 +490,6 @@ function RootStackLayout(props: { readonly children: React.ReactNode; readonly state: NavigationState; }) { - const navigation = useNavigation(); - const { pendingShare } = useIncomingShare(); - const sharePresentationRef = useRef(EMPTY_INCOMING_SHARE_PRESENTATION_STATE); const path = getPathFromState(props.state, navigationPathConfig); const pathname = path.startsWith("/") ? path : `/${path}`; const workspaceLocation = workspaceLocationFromState(props.state); @@ -519,22 +508,6 @@ function RootStackLayout(props: { useAgentNotificationNavigation(workspaceLocation.pathname); useConnectOnboardingNavigation(); useAppShortcuts(props.state); - useEffect(() => { - const transition = transitionIncomingSharePresentation(sharePresentationRef.current, { - isShareSheetPresented: isDraftThreadRoute(props.state.routes[props.state.index]), - pendingShareId: pendingShare?.id ?? null, - }); - sharePresentationRef.current = transition.state; - if (!transition.shareIdToPresent) { - return; - } - navigation.dispatch( - selectWorkspaceDetail({ - name: "Thread", - params: { draft: { incomingShareId: transition.shareIdToPresent } }, - }), - ); - }, [navigation, pendingShare, props.state]); return ( diff --git a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx index 62425e49af6ef..23b34d362beee 100644 --- a/apps/mobile/src/features/sharing/IncomingShareProvider.tsx +++ b/apps/mobile/src/features/sharing/IncomingShareProvider.tsx @@ -9,8 +9,8 @@ import { type ResolvedSharePayload, type SharePayload, } from "expo-sharing"; -import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { AppState, Platform } from "react-native"; +import { useSyncExternalStore } from "react"; +import { Platform } from "react-native"; import { buildIncomingShareDraft, @@ -21,28 +21,21 @@ import { import { createIncomingSharePayloadReader } from "./incoming-share-native"; import { IncomingShareInbox } from "./incoming-share-inbox"; import { persistComposerAttachmentFile } from "../../lib/composerImages"; +import { subscribeAppActive } from "../../lib/appState"; +import { coalescedRun } from "../../lib/coalescedRun"; +import { rootNavigationRef } from "../../lib/rootNavigation"; +import { selectWorkspaceDetail } from "../workspace/workspace-detail-selection"; +import { isDraftThreadRoute } from "../threads/thread-route-state"; +import { + EMPTY_INCOMING_SHARE_PRESENTATION_STATE, + transitionIncomingSharePresentation, +} from "./incoming-share-presentation"; import { loadIncomingShareDrafts, removeIncomingShareDraft, writeIncomingShareDraft, } from "./incoming-share-storage"; -type IncomingShareContextValue = { - readonly pendingShare: IncomingShareDraft | null; - readonly isLoading: boolean; - readonly error: Error | null; - readonly getShare: (shareId: string) => IncomingShareDraft | null; - readonly reserveShare: (shareId: string, destination: IncomingShareDestination) => Promise; - readonly releaseShareReservation: ( - shareId: string, - expectedDestination: IncomingShareDestination, - ) => Promise; - readonly consumeShare: (shareId: string) => Promise; - readonly refresh: () => Promise; -}; - -const IncomingShareContext = React.createContext(null); - function receiveSharingEnabled(): boolean { if (Platform.OS === "android") { return true; @@ -197,153 +190,109 @@ const incomingShareInbox = new IncomingShareInbox({ }, }); -export function IncomingShareProvider(props: React.PropsWithChildren) { - const enabled = receiveSharingEnabled(); - const [drafts, setDrafts] = useState>([]); - const [isLoading, setIsLoading] = useState(true); - const [error, setError] = useState(null); - const refreshPromiseRef = useRef | null>(null); - const mountedRef = useRef(false); +interface IncomingShareSnapshot { + readonly drafts: ReadonlyArray; + readonly isLoading: boolean; +} - useEffect(() => { - mountedRef.current = true; - return () => { - mountedRef.current = false; - }; - }, []); +let snapshot: IncomingShareSnapshot = { drafts: [], isLoading: true }; +const snapshotListeners = new Set<() => void>(); +let presentation = EMPTY_INCOMING_SHARE_PRESENTATION_STATE; - const refresh = useCallback(async () => { - if (refreshPromiseRef.current) { - return refreshPromiseRef.current; - } +function publish(drafts: ReadonlyArray) { + snapshot = { drafts, isLoading: false }; + for (const listener of snapshotListeners) listener(); + presentPendingIncomingShare(); +} - const operation = (async () => { - try { - const snapshot = await incomingShareInbox.refresh({ ingestNative: enabled }); - if (mountedRef.current) { - setDrafts(snapshot); - setError(null); - } - } catch (cause) { - const persisted = await incomingShareInbox - .refresh({ ingestNative: false }) - .catch(() => null); - if (mountedRef.current) { - if (persisted) { - setDrafts(persisted); - } - setError(cause instanceof Error ? cause : new Error("Could not import shared content.")); - } - } - })().finally(() => { - refreshPromiseRef.current = null; - }); +function subscribeIncomingShares(onChange: () => void) { + snapshotListeners.add(onChange); + return () => { + snapshotListeners.delete(onChange); + }; +} - refreshPromiseRef.current = operation; - return operation; - }, [enabled]); +function getIncomingShareSnapshot() { + return snapshot; +} - useEffect(() => { - void refresh().finally(() => { - if (mountedRef.current) { - setIsLoading(false); - } - }); - }, [refresh]); +function reportRefreshFailure(cause: unknown) { + const error = cause instanceof Error ? cause : new Error("Could not import shared content."); + Dialog.alert("Could not import shared content", error.message, [ + { text: "Dismiss", style: "cancel" }, + { text: "Retry", onPress: () => void refreshIncomingShares() }, + ]); +} - const refreshOnAppActive = useEffectEvent(() => { - void refresh(); - }); +// A resume while a refresh runs may carry a native handoff that refresh already +// passed, so it queues one more refresh instead of joining the running one. +const refreshIncomingShares = coalescedRun(async () => { + const enabled = receiveSharingEnabled(); + try { + publish(await incomingShareInbox.refresh({ ingestNative: enabled })); + } catch (cause) { + const persisted = await incomingShareInbox.refresh({ ingestNative: false }).catch(() => null); + publish(persisted ?? snapshot.drafts); + reportRefreshFailure(cause); + } +}); - useEffect(() => { - if (!enabled) { - return; - } - const subscription = AppState.addEventListener("change", (state) => { - if (state === "active") { - refreshOnAppActive(); - } - }); - return () => subscription.remove(); - }, [enabled]); +/** + * Opens the oldest pending share as a new-task draft unless the user already + * dismissed it. Runs whenever the inbox or the root navigation state changes. + */ +export function presentPendingIncomingShare(): void { + if (!rootNavigationRef.isReady()) return; + const state = rootNavigationRef.getRootState(); + if (!state) return; + const transition = transitionIncomingSharePresentation(presentation, { + isShareSheetPresented: isDraftThreadRoute(state.routes[state.index]), + pendingShareId: snapshot.drafts[0]?.id ?? null, + }); + presentation = transition.state; + if (!transition.shareIdToPresent) return; + rootNavigationRef.dispatch( + selectWorkspaceDetail({ + name: "Thread", + params: { draft: { incomingShareId: transition.shareIdToPresent } }, + }), + ); +} - useEffect(() => { - if (!error) { - return; - } - Dialog.alert("Could not import shared content", error.message, [ - { text: "Dismiss", style: "cancel", onPress: () => setError(null) }, - { - text: "Retry", - onPress: () => { - setError(null); - void refresh(); - }, - }, - ]); - }, [error, refresh]); +/** App-lifetime: loads the inbox and re-ingests native handoffs on every resume. */ +export function startIncomingShareInbox(): () => void { + void refreshIncomingShares(); + return receiveSharingEnabled() + ? subscribeAppActive(() => void refreshIncomingShares()) + : () => {}; +} - const consumeShare = useCallback(async (shareId: string) => { - const snapshot = await incomingShareInbox.consume(shareId); - if (mountedRef.current) { - setDrafts(snapshot); - } - }, []); - const reserveShare = useCallback( - async (shareId: string, destination: IncomingShareDestination) => { - const snapshot = await incomingShareInbox.reserve(shareId, destination); - if (mountedRef.current) { - setDrafts(snapshot); - } - }, - [], - ); - const releaseShareReservation = useCallback( - async (shareId: string, expectedDestination: IncomingShareDestination) => { - const snapshot = await incomingShareInbox.releaseReservation(shareId, expectedDestination); - if (mountedRef.current) { - setDrafts(snapshot); - } - }, - [], - ); - const getShare = useCallback( - (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, - [drafts], - ); +async function consumeShare(shareId: string) { + publish(await incomingShareInbox.consume(shareId)); +} - const value = useMemo( - () => ({ - pendingShare: drafts[0] ?? null, - isLoading, - error, - getShare, - releaseShareReservation, - reserveShare, - consumeShare, - refresh, - }), - [ - consumeShare, - drafts, - error, - getShare, - isLoading, - refresh, - releaseShareReservation, - reserveShare, - ], - ); +async function reserveShare(shareId: string, destination: IncomingShareDestination) { + publish(await incomingShareInbox.reserve(shareId, destination)); +} - return ( - {props.children} - ); +async function releaseShareReservation( + shareId: string, + expectedDestination: IncomingShareDestination, +) { + publish(await incomingShareInbox.releaseReservation(shareId, expectedDestination)); } -export function useIncomingShare(): IncomingShareContextValue { - const value = React.use(IncomingShareContext); - if (value === null) { - throw new Error("useIncomingShare must be used within IncomingShareProvider."); - } - return value; +export function useIncomingShare() { + const { drafts, isLoading } = useSyncExternalStore( + subscribeIncomingShares, + getIncomingShareSnapshot, + ); + return { + pendingShare: drafts[0] ?? null, + isLoading, + getShare: (shareId: string) => drafts.find((draft) => draft.id === shareId) ?? null, + reserveShare, + releaseShareReservation, + consumeShare, + }; } diff --git a/apps/mobile/src/lib/appState.ts b/apps/mobile/src/lib/appState.ts index e3c3cbc933747..8e1b5dfa9b4ed 100644 --- a/apps/mobile/src/lib/appState.ts +++ b/apps/mobile/src/lib/appState.ts @@ -12,6 +12,14 @@ export function isAppActive(): boolean { return AppState.currentState === "active"; } +/** Runs `onActive` each time the app returns to the foreground. */ +export function subscribeAppActive(onActive: Listener): () => void { + const subscription = AppState.addEventListener("change", (state) => { + if (state === "active") onActive(); + }); + return () => subscription.remove(); +} + export function useAppActive(): boolean { return useSyncExternalStore(subscribeAppState, isAppActive); } diff --git a/apps/mobile/src/lib/coalescedRun.test.ts b/apps/mobile/src/lib/coalescedRun.test.ts new file mode 100644 index 0000000000000..b409da5d3c2f6 --- /dev/null +++ b/apps/mobile/src/lib/coalescedRun.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import { coalescedRun } from "./coalescedRun"; + +function deferredRuns() { + const runs: Array> = []; + const run = () => { + const next = Promise.withResolvers(); + runs.push(next); + return next.promise; + }; + return { runs, run }; +} + +describe("coalescedRun", () => { + it("queues one more run for calls made while a run is in flight", async () => { + const { runs, run } = deferredRuns(); + const refresh = coalescedRun(run); + + const first = refresh(); + const second = refresh(); + const third = refresh(); + expect(third).toBe(second); + expect(runs).toHaveLength(1); + + runs[0]!.resolve(); + await first; + await vi.waitFor(() => expect(runs).toHaveLength(2)); + + runs[1]!.resolve(); + await second; + expect(runs).toHaveLength(2); + }); + + it("hands a queued run's failure to the call that asked for it", async () => { + const { runs, run } = deferredRuns(); + const refresh = coalescedRun(run); + + const first = refresh(); + const second = refresh(); + runs[0]!.resolve(); + await first; + await vi.waitFor(() => expect(runs).toHaveLength(2)); + runs[1]!.reject(new Error("read failed")); + await expect(second).rejects.toThrow("read failed"); + }); + + it("runs once per call when calls do not overlap", async () => { + let count = 0; + const refresh = coalescedRun(async () => { + count += 1; + }); + await refresh(); + await refresh(); + expect(count).toBe(2); + }); +}); diff --git a/apps/mobile/src/lib/coalescedRun.ts b/apps/mobile/src/lib/coalescedRun.ts new file mode 100644 index 0000000000000..72a32cc6865bd --- /dev/null +++ b/apps/mobile/src/lib/coalescedRun.ts @@ -0,0 +1,26 @@ +/** + * Runs `run` at most once at a time. A call made while a run is in flight + * queues one more run after it and returns that queued run, so input that + * arrives mid-run (a native share handed over on resume) is still read, and + * the caller that asked for it sees how it ended. + */ +export function coalescedRun(run: () => Promise): () => Promise { + let inFlight: Promise | null = null; + let queued: Promise | null = null; + const start = (): Promise => { + if (inFlight) { + queued ??= inFlight + .catch(() => undefined) + .then(() => { + queued = null; + return start(); + }); + return queued; + } + inFlight = run().finally(() => { + inFlight = null; + }); + return inFlight; + }; + return start; +} From 6f6c6c14b76e72d7abd43b8e6ebffff17eadc89e Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 23:04:56 -0700 Subject: [PATCH 015/317] refactor(web): drop the unreachable paint-only thread timeline path (#25787) Co-authored-by: Claude Opus 5.5 --- .../web/src/components/ChatView.logic.test.ts | 10 +- apps/web/src/components/ChatView.logic.ts | 46 +---- apps/web/src/components/ChatView.tsx | 169 +++++------------- .../src/components/chat/MessagesTimeline.tsx | 6 +- .../src/components/chat/ThreadTimeline.tsx | 17 +- .../src/components/chat/useTimelineRewind.ts | 4 +- 6 files changed, 62 insertions(+), 190 deletions(-) diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 969a5d2e24cc5..363e5db0b6071 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -45,7 +45,6 @@ import { rememberReadyThreadTimeline, resetHeldThreadTimeline, resolveThreadSwitchTimeline, - threadKeysShareEnvironment, timelineHasEphemeralPreviewUrls, createLocalDispatchSnapshot, deriveCommittedServerUserMessageIds, @@ -371,19 +370,18 @@ describe("resolveThreadSwitchTimeline", () => { activeThreadKey: "env-1:thread-a", nextEntries: [], }), - ).toEqual({ entries: ["a1", "a2"], displayThreadKey: "env-1:thread-a" }); + ).toEqual(["a1", "a2"]); }); - it("does not hold another environment's timeline across a jump", () => { - expect(threadKeysShareEnvironment("env-1:thread-a", "env-2:thread-b")).toBe(false); + it("does not paint the last-viewed thread for a destination with nothing remembered", () => { + rememberReadyThreadTimeline(held); expect( resolveThreadSwitchTimeline({ loading: true, activeThreadKey: "env-2:thread-b", nextEntries: [], - lastReady: held, }), - ).toEqual({ entries: [], displayThreadKey: "env-2:thread-b" }); + ).toEqual([]); }); it("does not remember a timeline that still has handoff blob previews", () => { diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 67c10d9dfeadf..0e36a3ef4681e 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -46,7 +46,6 @@ import { } from "@t3tools/contracts"; import { worktreeSetupAgentStarted } from "@t3tools/client-runtime/worktree-setup"; import * as DateTime from "effect/DateTime"; -import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; import { resolveAssetUrl } from "@t3tools/client-runtime/state/assets"; import { squashAtomCommandFailure, @@ -346,15 +345,12 @@ export function resolveDraftHeroState(input: { export type HeldThreadTimeline = { threadKey: string | null; entries: T; - markdownCwd?: string | null; - workspaceRoot?: string | null; }; const MAX_REMEMBERED_THREAD_TIMELINES = 16; let rememberedThreadTimelines = new Map>(); let rememberedThreadTimelineOrder: string[] = []; -let lastReadyThreadKey: string | null = null; function rememberThreadTimelineEntries(held: HeldThreadTimeline): void { if (held.threadKey === null) { @@ -371,7 +367,6 @@ function rememberThreadTimelineEntries(held: HeldThreadTimeline( @@ -392,41 +387,9 @@ export function peekRememberedThreadTimeline( return (rememberedThreadTimelines.get(threadKey)?.entries as T | undefined) ?? null; } -export function peekHeldThreadTimeline< - T extends readonly unknown[], ->(): HeldThreadTimeline | null { - if (lastReadyThreadKey === null) { - return null; - } - const held = rememberedThreadTimelines.get(lastReadyThreadKey); - if (held === undefined || held.entries.length === 0) { - return null; - } - return held as HeldThreadTimeline; -} - export function resetHeldThreadTimeline(): void { rememberedThreadTimelines = new Map(); rememberedThreadTimelineOrder = []; - lastReadyThreadKey = null; -} - -export function threadKeysShareEnvironment(left: string | null, right: string | null): boolean { - if (left === null || right === null) { - return false; - } - const leftRef = parseScopedThreadKey(left); - const rightRef = parseScopedThreadKey(right); - return leftRef !== null && rightRef !== null && leftRef.environmentId === rightRef.environmentId; -} - -export function isPaintOnlyThreadTimeline( - displayThreadKey: string | null, - activeThreadKey: string | null, -): boolean { - return ( - displayThreadKey !== null && activeThreadKey !== null && displayThreadKey !== activeThreadKey - ); } export function resolveThreadSwitchTimeline(input: { @@ -434,19 +397,18 @@ export function resolveThreadSwitchTimeline(input: activeThreadKey: string | null; nextEntries: T; rememberedForActive?: T | null; - lastReady?: HeldThreadTimeline | null; -}): { entries: T; displayThreadKey: string | null } { +}): T { if (input.nextEntries.length > 0) { - return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; + return input.nextEntries; } const rememberedForActive = input.rememberedForActive ?? peekRememberedThreadTimeline(input.activeThreadKey); if (input.loading && rememberedForActive !== null && rememberedForActive.length > 0) { - return { entries: rememberedForActive, displayThreadKey: input.activeThreadKey }; + return rememberedForActive; } - return { entries: input.nextEntries, displayThreadKey: input.activeThreadKey }; + return input.nextEntries; } export function startNewThreadForProject( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 462728f093c24..6486137a171ca 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -32,14 +32,7 @@ import { THREAD_FIND_BAR_RESERVED_HEIGHT } from "./chat/ThreadFindBar"; import { usageLimitRecoveryBannerItem } from "./chat/UsageLimitRecoveryBanner"; import { resolveDraftHeroState } from "./ChatView.logic"; import { useQueuedRunEdit } from "./chat/useQueuedRunEdit"; -import { - isPaintOnlyThreadTimeline, - peekHeldThreadTimeline, - peekRememberedThreadTimeline, - resolveThreadSwitchTimeline, - recallCheckoutIsRepo, - rememberCheckoutIsRepo, -} from "./ChatView.logic"; +import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { useStableCallback } from "../hooks/useStableCallback"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; @@ -74,7 +67,6 @@ import { type ServerProvider, type ScopedThreadRef, type ThreadId, - type RunId, ProviderInteractionMode, resolveIdleCompaction, RuntimeMode, @@ -405,11 +397,6 @@ type ChatViewProps = draftId: DraftId; }; -const EMPTY_HELD_TURN_DIFF_SUMMARIES: readonly never[] = []; -const noopHeldTurnDiff = (_turnId: RunId, _filePath?: string) => {}; -const noopHeldRevert = (_targetTurnCount: number) => {}; -const noopHeldAttachment = (_attachment: ChatFileAttachment) => {}; - export default function ChatView(props: ChatViewProps) { const { environmentId, @@ -1469,17 +1456,7 @@ export default function ChatView(props: ChatViewProps) { entry.kind === "message" ? [entry.message] : [], ); const timelineEmpty = timelineEntries.length === 0 && !hasStreamedTimelineRows; - const displayedTimeline = resolveThreadSwitchTimeline({ - loading: timelineEmpty && threadSyncPhase !== null, - activeThreadKey, - nextEntries: timelineEntries, - rememberedForActive: peekRememberedThreadTimeline(activeThreadKey), - }); - const displayedTimelineKey = displayedTimeline.displayThreadKey ?? routeThreadKey; - const paintOnlyDisplayedTimeline = isPaintOnlyThreadTimeline( - displayedTimeline.displayThreadKey, - activeThreadKey, - ); + const displayedTimelineKey = activeThreadKey ?? routeThreadKey; const displayedThreadRef = parseScopedThreadKey(displayedTimelineKey); const { worktreeSetup, @@ -1624,9 +1601,6 @@ export default function ChatView(props: ChatViewProps) { const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadWorktreePath = activeThread?.worktreePath ?? null; const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; - const heldPaintContext = paintOnlyDisplayedTimeline - ? peekHeldThreadTimeline() - : null; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; const liveIsGitRepo = gitStatusQuery.data?.isRepo; @@ -2590,8 +2564,7 @@ export default function ChatView(props: ChatViewProps) { const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; - const threadFindEnabled = - isServerThread && serverConfig?.threadFind === true && !paintOnlyDisplayedTimeline; + const threadFindEnabled = isServerThread && serverConfig?.threadFind === true; const isThreadFindActive = useThreadFindOpen(activeThreadRef, threadFindEnabled); useChatShortcuts({ @@ -2669,7 +2642,6 @@ export default function ChatView(props: ChatViewProps) { serverProjection, activeThread, timelineMessages, - paintOnlyDisplayedTimeline, phase, isConnecting, isSendBusy, @@ -3249,126 +3221,79 @@ export default function ChatView(props: ChatViewProps) { activeThreadKey={activeThreadKey} syncing={threadSyncPhase !== null} detailLoading={threadDetailLoading} - readyMarkdownCwd={gitCwd} - readyWorkspaceRoot={activeWorkspaceRoot ?? null} - citationRequest={paintOnlyDisplayedTimeline ? null : citationRequest} + citationRequest={citationRequest} citationHistory={citationHistory} - {...(!paintOnlyDisplayedTimeline - ? { - onCiteAssistantText: citeAssistantTextStable, - ...(activeProject ? { onRunShellCommand: runShellCommandStable } : {}), - } - : {})} - isWorking={!paintOnlyDisplayedTimeline && isWorking} + onCiteAssistantText={citeAssistantTextStable} + {...(activeProject ? { onRunShellCommand: runShellCommandStable } : {})} + isWorking={isWorking} runlessWorkActive={runlessWorkStartedAt !== null} - activeTurnInProgress={ - !paintOnlyDisplayedTimeline && (isWorking || !latestRunSettled) - } - isCompacting={!paintOnlyDisplayedTimeline && isCompacting} + activeTurnInProgress={isWorking || !latestRunSettled} + isCompacting={isCompacting} awaitingUser={ - paintOnlyDisplayedTimeline - ? null - : activePendingApproval !== null - ? "approval" - : activePendingUserInput !== null || - // A secret request has no runtime request; the shell carries it. - activeThreadShell?.hasPendingUserInput === true - ? "input" - : null + activePendingApproval !== null + ? "approval" + : activePendingUserInput !== null || + // A secret request has no runtime request; the shell carries it. + activeThreadShell?.hasPendingUserInput === true + ? "input" + : null } awaitingRequestId={ - paintOnlyDisplayedTimeline - ? null - : (activePendingApproval?.requestId ?? - activePendingUserInput?.requestId ?? - null) + activePendingApproval?.requestId ?? activePendingUserInput?.requestId ?? null } - activeTurnStartedAt={paintOnlyDisplayedTimeline ? null : activeWorkStartedAt} - worktreeSetup={paintOnlyDisplayedTimeline ? null : worktreeSetup} + activeTurnStartedAt={activeWorkStartedAt} + worktreeSetup={worktreeSetup} onCancelWorktreeSetup={onCancelWorktreeSetupStable} - {...(paintOnlyDisplayedTimeline - ? {} - : { - retryableWorkspacePreparationRunIds, - onRetryWorkspacePreparation: onRetryWorkspacePreparationStable, - })} + retryableWorkspacePreparationRunIds={retryableWorkspacePreparationRunIds} + onRetryWorkspacePreparation={onRetryWorkspacePreparationStable} {...(draftId ? { onWorktreeSetupWorkLocally: onWorktreeSetupWorkLocallyStable } : {})} {...(onOpenWorktreeSetupTerminal ? { onOpenWorktreeSetupTerminal } : {})} - isPreparingWorktree={!paintOnlyDisplayedTimeline && isPreparingWorktree} - footer={paintOnlyDisplayedTimeline ? null : timelineFooter} + isPreparingWorktree={isPreparingWorktree} + footer={timelineFooter} listRef={legendListRef} - runs={paintOnlyDisplayedTimeline ? [] : (serverProjection?.runs ?? [])} - latestRun={paintOnlyDisplayedTimeline ? null : activeActivityRun} - announcedRun={ - paintOnlyDisplayedTimeline - ? null - : announcedRun(activeActivityRun, serverProjection?.runs ?? []) - } - detailBehindShell={ - !paintOnlyDisplayedTimeline && - isDetailBehindShell(activeLatestRun, activeThreadShell?.latestRun ?? null) - } - runningRunId={paintOnlyDisplayedTimeline ? null : activeRunningTurnId} - turnDiffSummaries={ - paintOnlyDisplayedTimeline ? EMPTY_HELD_TURN_DIFF_SUMMARIES : turnDiffSummaries - } + runs={serverProjection?.runs ?? []} + latestRun={activeActivityRun} + announcedRun={announcedRun(activeActivityRun, serverProjection?.runs ?? [])} + detailBehindShell={isDetailBehindShell( + activeLatestRun, + activeThreadShell?.latestRun ?? null, + )} + runningRunId={activeRunningTurnId} + turnDiffSummaries={turnDiffSummaries} activeThreadEnvironmentId={ displayedThreadRef?.environmentId ?? activeThread.environmentId } routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} - paintOnly={paintOnlyDisplayedTimeline} announceTurnStatus={!isProviderSubagent} - onOpenTurnDiff={ - paintOnlyDisplayedTimeline ? noopHeldTurnDiff : onOpenTurnDiffStable - } + onOpenTurnDiff={onOpenTurnDiffStable} onOpenThread={onOpenRelatedThreadStable} - parentThreadLink={paintOnlyDisplayedTimeline ? null : parentThreadLink} - workflowOpening={paintOnlyDisplayedTimeline ? null : workflowOpening} - onForkFromRun={paintOnlyDisplayedTimeline ? async () => {} : onForkFromRunStable} + parentThreadLink={parentThreadLink} + workflowOpening={workflowOpening} + onForkFromRun={onForkFromRunStable} onRollbackCheckpoint={onTimelineRollbackCheckpointStable} - supportsConversationRollback={ - !paintOnlyDisplayedTimeline && supportsConversationRollback - } + supportsConversationRollback={supportsConversationRollback} threadStartCheckpointReady={threadStartCheckpointReady} availableRevertTurnCounts={availableRevertTurnCounts} - onRevertToTurnCount={ - paintOnlyDisplayedTimeline ? noopHeldRevert : onRevertTimelineTurnStable - } - {...(!paintOnlyDisplayedTimeline - ? { - onUseArtifactTemplate: insertArtifactTemplateStable, - onSendAppMessage: sendAppMessageStable, - } - : {})} + onRevertToTurnCount={onRevertTimelineTurnStable} + onUseArtifactTemplate={insertArtifactTemplateStable} + onSendAppMessage={sendAppMessageStable} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImageStable} - onFileOpen={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : openFileAttachmentStable - } - onFileDownload={ - paintOnlyDisplayedTimeline ? noopHeldAttachment : downloadFileAttachmentStable - } - markdownCwd={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.markdownCwd ?? undefined) - : (gitCwd ?? undefined) - } + onFileOpen={openFileAttachmentStable} + onFileDownload={downloadFileAttachmentStable} + markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} timestampFormat={timestampFormat} - workspaceRoot={ - paintOnlyDisplayedTimeline - ? (heldPaintContext?.workspaceRoot ?? undefined) - : activeWorkspaceRoot - } + workspaceRoot={activeWorkspaceRoot} skills={timelineSkills} - anchorMessageId={paintOnlyDisplayedTimeline ? null : timelineAnchorMessageId} + anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReadyStable} onAnchorSizeChanged={onTimelineAnchorSizeChangedStable} contentInsetEndAdjustment={composerTimelineInset} - liveFollowEnabled={!paintOnlyDisplayedTimeline && timelineLiveFollowEnabled} + liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} onContentOverflowChange={setTimelineOverflows} onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} @@ -3377,7 +3302,7 @@ export default function ChatView(props: ChatViewProps) { hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} emptyPlaceholderInset={composerReservedHeight} topFadeEnabled={!hasTimelineTopBanner} - {...(paintOnlyDisplayedTimeline || threadHistoryControls === undefined + {...(threadHistoryControls === undefined ? {} : { historyControls: threadHistoryControls })} /> diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index e9b21061c47d7..fbfa67e61bf8e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -231,7 +231,6 @@ interface MessagesTimelineProps { turnDiffSummaries: ReadonlyArray; routeThreadKey: string; displayThreadKey?: string; - paintOnly?: boolean; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; @@ -339,7 +338,6 @@ const ConversationTimeline = memo(function ConversationTimeline({ turnDiffSummaries, routeThreadKey, displayThreadKey, - paintOnly = false, announceTurnStatus = true, onOpenTurnDiff, onOpenThread, @@ -906,7 +904,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ if (restoringThreadPosition || state?.data !== rows) return; const isAtEnd = resolveTimelineIsAtEnd(state); const position = state.data.length ? resolveWorkGroupScrollAnchor(state) : undefined; - if (position && isAtEnd !== undefined && !paintOnly) { + if (position && isAtEnd !== undefined) { const index = state.indexByKey(position.rowId); const row = index === undefined ? undefined : state.elementAtIndex(index); const element = listRef.current?.getScrollableNode(); @@ -1075,7 +1073,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ // starts from a baseline instead of a fresh, silent region. const activityAnnouncer = ( , "timelineEntries"> & { /** Null until the thread's detail may load; see resolveThreadDetailRef. */ @@ -35,8 +33,6 @@ export function ThreadTimeline({ activeThreadKey: string | null; syncing: boolean; detailLoading: boolean; - readyMarkdownCwd: string | null; - readyWorkspaceRoot: string | null; }) { const visibleTurnItems = useThreadVisibleTurnItems(threadDetailRef); const serverEntries = useIncrementalTimelineEntries(activeThreadKey, { @@ -44,7 +40,7 @@ export function ThreadTimeline({ visibleTurnItems, }); const entries = draftEntries ?? serverEntries; - const displayed = resolveThreadSwitchTimeline({ + const displayedEntries = resolveThreadSwitchTimeline({ loading: entries.length === 0 && syncing, activeThreadKey, nextEntries: entries, @@ -54,12 +50,7 @@ export function ThreadTimeline({ if (detailLoading || entries.length === 0 || timelineHasEphemeralPreviewUrls(entries)) { return; } - rememberReadyThreadTimeline({ - threadKey: activeThreadKey, - entries, - markdownCwd: readyMarkdownCwd, - workspaceRoot: readyWorkspaceRoot, - }); - }, [activeThreadKey, detailLoading, entries, readyMarkdownCwd, readyWorkspaceRoot]); - return ; + rememberReadyThreadTimeline({ threadKey: activeThreadKey, entries }); + }, [activeThreadKey, detailLoading, entries]); + return ; } diff --git a/apps/web/src/components/chat/useTimelineRewind.ts b/apps/web/src/components/chat/useTimelineRewind.ts index 6c7aef0a75693..b222fc55ec46f 100644 --- a/apps/web/src/components/chat/useTimelineRewind.ts +++ b/apps/web/src/components/chat/useTimelineRewind.ts @@ -41,7 +41,6 @@ interface TimelineRewindInput { readonly serverProjection: ThreadStructure | null; readonly activeThread: EnvironmentThreadShell | undefined; readonly timelineMessages: ReadonlyArray; - readonly paintOnlyDisplayedTimeline: boolean; readonly phase: SessionPhase; readonly isConnecting: boolean; readonly isSendBusy: boolean; @@ -68,7 +67,6 @@ export function useTimelineRewind(input: TimelineRewindInput) { serverProjection, activeThread, timelineMessages, - paintOnlyDisplayedTimeline, phase, isConnecting, isSendBusy, @@ -270,7 +268,7 @@ export function useTimelineRewind(input: TimelineRewindInput) { readonly checkpointId: string; readonly scopeId: string; }) => { - if (!paintOnlyDisplayedTimeline) void onRollbackCheckpoint(input); + void onRollbackCheckpoint(input); }; const onTimelineRollbackCheckpointStable = useStableCallback(onTimelineRollbackCheckpoint); const onRevertTimelineTurn = (targetTurnCount: number, messageId: MessageId) => { From 401b01747c1f7b331fb00b573e405a38f646fde8 Mon Sep 17 00:00:00 2001 From: "t3-code[bot]" <269035359+t3-code[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:10:05 +0000 Subject: [PATCH 016/317] chore(release): prepare v0.1.3 --- apps/desktop/package.json | 2 +- apps/server/package.json | 2 +- apps/web/package.json | 2 +- packages/contracts/package.json | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f87fa20453f36..ad70e206af643 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/desktop", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "main": "dist-electron/boot.cjs", diff --git a/apps/server/package.json b/apps/server/package.json index db581556a13e7..cf243b40c1071 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "t3", - "version": "0.1.2", + "version": "0.1.3", "license": "MIT", "repository": { "type": "git", diff --git a/apps/web/package.json b/apps/web/package.json index 5ae86a8a75c62..9ab88beb3d7f5 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/web", - "version": "0.1.2", + "version": "0.1.3", "private": true, "type": "module", "scripts": { diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 6b5272917383c..c87a9fed41033 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@t3tools/contracts", - "version": "0.1.2", + "version": "0.1.3", "private": true, "files": [ "dist" From 0b02b21e5a451fa7e488a8596cedbeb0ddd9d63c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Mon, 26 Oct 2026 23:17:34 -0700 Subject: [PATCH 017/317] refactor(mobile): notifications, quick actions and Connect onboarding navigate through the root ref (#25769) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 60 +++++-- apps/mobile/src/Stack.tsx | 18 ++- .../agent-awareness/notificationNavigation.ts | 68 ++++---- .../features/cloud/connectOnboarding.test.ts | 74 +++++++++ .../src/features/cloud/connectOnboarding.ts | 45 +++++- .../cloud/connectOnboardingNavigation.ts | 41 ----- .../features/cloud/connectOnboardingOptOut.ts | 4 +- .../features/home/home-route-visibility.ts | 74 +++++---- .../shortcuts/appShortcutsRuntime.test.ts | 70 ++++++++ .../features/shortcuts/appShortcutsRuntime.ts | 149 ++++++++++++++++++ .../src/features/shortcuts/useAppShortcuts.ts | 125 --------------- apps/mobile/src/lib/rootNavigation.test.ts | 52 +++++- apps/mobile/src/lib/rootNavigation.ts | 40 ++++- scripts/effect-budget.json | 1 - 14 files changed, 560 insertions(+), 261 deletions(-) create mode 100644 apps/mobile/src/features/cloud/connectOnboarding.test.ts delete mode 100644 apps/mobile/src/features/cloud/connectOnboardingNavigation.ts create mode 100644 apps/mobile/src/features/shortcuts/appShortcutsRuntime.test.ts create mode 100644 apps/mobile/src/features/shortcuts/appShortcutsRuntime.ts delete mode 100644 apps/mobile/src/features/shortcuts/useAppShortcuts.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 579aea37bfa4d..b7c054d9a64ac 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -9,9 +9,12 @@ import { GestureHandlerRootView } from "react-native-gesture-handler"; import { KeyboardProvider } from "react-native-keyboard-controller"; import { SafeAreaProvider } from "react-native-safe-area-context"; import { + CommonActions, + createPathConfigForStaticNavigation, createStaticNavigation, getActionFromState, getStateFromPath, + type NavigationAction, type NavigationState, } from "@react-navigation/native"; @@ -25,12 +28,20 @@ import { presentPendingIncomingShare, startIncomingShareInbox, } from "./features/sharing/IncomingShareProvider"; +import { + installAgentNotificationNavigation, + setAgentNotificationPathname, +} from "./features/agent-awareness/notificationNavigation"; +import { + installAppShortcuts, + recordAppShortcutNavigationState, +} from "./features/shortcuts/appShortcutsRuntime"; import { AppearancePreferencesProvider, installAppearanceRuntime, useAppearancePreferences, } from "./features/settings/appearance/AppearancePreferencesProvider"; -import { RootStack } from "./Stack"; +import { RootStack, workspaceLocationFromState } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { subscribeComposerDraftModelReconciliation } from "./state/use-composer-drafts"; import { environmentThreadShells } from "./state/threads"; @@ -57,7 +68,12 @@ import { } from "./features/workspace/workspace-selection-store"; import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; -import { attachRootNavigation, rootNavigationRef } from "./lib/rootNavigation"; +import { + attachRootNavigation, + flushRootNavigationQueue, + linkToRootPath, + rootNavigationRef, +} from "./lib/rootNavigation"; import { pruneNewTaskBranchCheckouts } from "./features/threads/NewTaskDraftRouteScreen"; import "../global.css"; @@ -69,6 +85,20 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { void SplashScreen.preventAutoHideAsync().catch(() => {}); void hydrateWorkspaceSelections(); +const linkingConfig = { + initialRouteName: "Home", + screens: createPathConfigForStaticNavigation(RootStack, { initialRouteName: "Home" }) ?? {}, +} as const; + +function getAppStateFromPath(path: string, options: Parameters[1]) { + return takeScheduledTaskNewLink( + restoreSettingsLinkState( + restoreDraftLinkState(getStateFromPath(path, options), path), + settingsLinkPage(), + ), + ); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, @@ -81,25 +111,33 @@ const appLinking = { // Our router handles the selection; React Navigation's type only lists its own actions. (workspaceDetailLinkAction(state) as ReturnType) ?? getActionFromState(state, options), - getStateFromPath: (path: string, options: Parameters[1]) => - takeScheduledTaskNewLink( - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), - ), + getStateFromPath: getAppStateFromPath, }; +function appLinkAction(href: string): NavigationAction { + const state = getAppStateFromPath(href, linkingConfig); + if (!state) throw new Error(`Failed to parse the href to a navigation state (${href}).`); + return appLinking.getActionFromState(state, linkingConfig) ?? CommonActions.reset(state); +} + +function linkToAppPath(href: string): void { + linkToRootPath(href, appLinkAction); +} + const Navigation = createStaticNavigation(RootStack); function handleNavigationStateChange(state: NavigationState | undefined): void { recordWorkspaceSelections(state); - if (state) pruneNewTaskBranchCheckouts(state); presentPendingIncomingShare(); + if (!state) return; + setAgentNotificationPathname(workspaceLocationFromState(state).pathname); + recordAppShortcutNavigationState(state); + pruneNewTaskBranchCheckouts(state); } function handleNavigationReady(): void { handleNavigationStateChange(rootNavigationRef.getRootState()); + flushRootNavigationQueue(); } declare const module: { readonly hot?: FoundationHotModule } | undefined; @@ -118,6 +156,8 @@ const appServiceDisposers = [ installThreadOutboxDelivery(), installComposerAttachmentUploads(), startIncomingShareInbox(), + installAgentNotificationNavigation(linkToAppPath), + installAppShortcuts(linkToAppPath), ]; disposeOnFoundationReplace(typeof module === "undefined" ? undefined : module.hot, () => { for (const dispose of appServiceDisposers) dispose(); diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7312807c55e48..396759a5f04b0 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -24,6 +24,11 @@ import { androidWorkspaceDestinationAnimation } from "./lib/android-workspace-na import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { WorkspaceConnectionStatusProvider } from "./features/home/WorkspaceConnectionTitle"; import { WorkspaceRootScreen } from "./features/workspace/WorkspaceRootScreen"; +import { + recordHomeFocus, + recordHomeTransitionEnd, + recordHomeTransitionStart, +} from "./features/home/home-route-visibility"; import { AndroidWorkspaceTabsProvider } from "./features/workspace/AndroidWorkspaceTabsProvider"; import { PullRequestRouteScreen, @@ -41,9 +46,7 @@ import { type RenderFailureProps, } from "./components/RenderErrorBoundary"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; -import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ConnectOnboardingRouteScreen } from "./features/cloud/ConnectOnboardingRouteScreen"; -import { useConnectOnboardingNavigation } from "./features/cloud/connectOnboardingNavigation"; import { AttachmentFileScreen } from "./features/files/AttachmentFileScreen"; import { ThreadFilesTreeScreen, ThreadFileScreen } from "./features/files/ThreadFilesRouteScreen"; import { AdaptiveWorkspaceLayout } from "./features/layout/AdaptiveWorkspaceLayout"; @@ -138,7 +141,6 @@ import { SettingsLegalDocumentCloseHeaderButton, SettingsLegalDocumentExternalHeaderButton, } from "./features/settings/components/SettingsLegalDocumentRouteScreen"; -import { useAppShortcuts } from "./features/shortcuts/useAppShortcuts"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "./native/native-glass"; import { deriveLayout } from "./lib/layout"; import { useNativeLayoutMetrics } from "./native/native-layout-metrics"; @@ -473,7 +475,7 @@ const SETTINGS_SCREENS = { // iOS 26 header morphing requires one UINavigationBar; nested stacks make the whole screen slide. const THREAD_LINKING_PREFIX = "threads/:environmentId/:threadId"; -function workspaceLocationFromState(state: NavigationState) { +export function workspaceLocationFromState(state: NavigationState) { const routes = state.routes.filter((route) => !isWorkspaceOverlayRoute(route.name)); const effectiveState = routes.length > 0 && routes.length !== state.routes.length @@ -505,9 +507,6 @@ function RootStackLayout(props: { const routeDraftId = Array.isArray(draftParams?.draftId) ? draftParams.draftId[0] : draftParams?.draftId; - useAgentNotificationNavigation(workspaceLocation.pathname); - useConnectOnboardingNavigation(); - useAppShortcuts(props.state); return ( @@ -592,6 +591,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ Home: createNativeStackScreen({ screen: WorkspaceRootScreen, linking: "", + listeners: { + transitionStart: ({ data }) => recordHomeTransitionStart(data.closing), + transitionEnd: ({ data }) => recordHomeTransitionEnd(data.closing), + focus: recordHomeFocus, + }, options: { ...GLASS_HEADER_OPTIONS, ...FIXED_DUO_HEADER, diff --git a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts index 83de9054a1e7e..7d2317e58baae 100644 --- a/apps/mobile/src/features/agent-awareness/notificationNavigation.ts +++ b/apps/mobile/src/features/agent-awareness/notificationNavigation.ts @@ -1,50 +1,42 @@ -import { useEffect, useLayoutEffect, useRef } from "react"; import * as Notifications from "expo-notifications"; -import { useLinkTo } from "@react-navigation/native"; import { setAndroidThreadOnScreen } from "./androidNotifications"; import { foregroundNotificationBehavior } from "./foregroundNotificationBehavior"; import { routeAgentNotificationResponseOnce, threadDeepLinkOnScreen } from "./notificationPayload"; import { consumeLastAgentNotificationResponse } from "./notificationResponseConsumer"; -export function useAgentNotificationNavigation(pathname: string): void { - const linkTo = useLinkTo(); - const handledResponseIds = useRef(new Set()); - const deepLinkOnScreen = useRef(null); - useLayoutEffect(() => { - const thread = threadDeepLinkOnScreen(pathname); - deepLinkOnScreen.current = thread; - setAndroidThreadOnScreen(thread); - }, [pathname]); +let deepLinkOnScreen: string | null = null; - useEffect(() => { - Notifications.setNotificationHandler({ - handleNotification: (notification) => - Promise.resolve(foregroundNotificationBehavior(notification, deepLinkOnScreen.current)), - }); - return () => { - Notifications.setNotificationHandler(null); - }; - }, []); +/** Called with the workspace pathname on every root navigation state change. */ +export function setAgentNotificationPathname(pathname: string): void { + deepLinkOnScreen = threadDeepLinkOnScreen(pathname); + setAndroidThreadOnScreen(deepLinkOnScreen); +} - useEffect(() => { - const handleResponse = (response: Notifications.NotificationResponse): void => { - routeAgentNotificationResponseOnce({ - handledResponseIds: handledResponseIds.current, - response, - navigate: linkTo, - }); - }; +/** + * App-lifetime notification routing: foreground presentation suppresses the + * thread already on screen, and taps (including the cold-start tap) open the + * notification's deep link through `navigate`. + */ +export function installAgentNotificationNavigation(navigate: (href: string) => void): () => void { + const handledResponseIds = new Set(); + Notifications.setNotificationHandler({ + handleNotification: (notification) => + Promise.resolve(foregroundNotificationBehavior(notification, deepLinkOnScreen)), + }); - const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); - void consumeLastAgentNotificationResponse({ - getLastResponse: () => Notifications.getLastNotificationResponseAsync(), - clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), - handleResponse, - }); + const handleResponse = (response: Notifications.NotificationResponse): void => { + routeAgentNotificationResponseOnce({ handledResponseIds, response, navigate }); + }; + const subscription = Notifications.addNotificationResponseReceivedListener(handleResponse); + void consumeLastAgentNotificationResponse({ + getLastResponse: () => Notifications.getLastNotificationResponseAsync(), + clearLastResponse: () => Notifications.clearLastNotificationResponseAsync(), + handleResponse, + }); - return () => { - subscription.remove(); - }; - }, [linkTo]); + return () => { + subscription.remove(); + Notifications.setNotificationHandler(null); + }; } diff --git a/apps/mobile/src/features/cloud/connectOnboarding.test.ts b/apps/mobile/src/features/cloud/connectOnboarding.test.ts new file mode 100644 index 0000000000000..d79254c9e8148 --- /dev/null +++ b/apps/mobile/src/features/cloud/connectOnboarding.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; + +const navigation = vi.hoisted(() => ({ + navigate: vi.fn(), + ready: true, + queue: [] as Array<() => void>, +})); +const optOut = vi.hoisted(() => ({ optedOut: new Set() })); + +vi.mock("../../lib/rootNavigation", () => ({ + rootNavigationRef: navigation, + whenRootNavigationReady: (navigate: () => void) => + navigation.ready ? navigate() : navigation.queue.push(navigate), +})); +vi.mock("./connectOnboardingOptOut", () => ({ + isConnectOnboardingOptedOut: (accountId: string) => + Promise.resolve(optOut.optedOut.has(accountId)), +})); + +import { clearConnectOnboardingRequest, requestConnectOnboarding } from "./connectOnboarding"; + +afterEach(() => { + clearConnectOnboardingRequest(); + optOut.optedOut.clear(); + navigation.navigate.mockReset(); + navigation.ready = true; + navigation.queue.length = 0; + vi.useRealTimers(); +}); + +describe("requestConnectOnboarding", () => { + it("presents the sheet after the settle delay", async () => { + vi.useFakeTimers(); + requestConnectOnboarding("account-1"); + await vi.advanceTimersByTimeAsync(599); + expect(navigation.navigate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(navigation.navigate).toHaveBeenCalledExactlyOnceWith("ConnectOnboarding"); + }); + + it("drops a request cleared by sign-out before it fires", async () => { + vi.useFakeTimers(); + requestConnectOnboarding("account-1"); + clearConnectOnboardingRequest(); + await vi.runAllTimersAsync(); + expect(navigation.navigate).not.toHaveBeenCalled(); + }); + + it("skips accounts that opted out", async () => { + vi.useFakeTimers(); + optOut.optedOut.add("account-1"); + requestConnectOnboarding("account-1"); + await vi.runAllTimersAsync(); + expect(navigation.navigate).not.toHaveBeenCalled(); + }); + + it("presents once when a newer request replaces a pending one", async () => { + vi.useFakeTimers(); + requestConnectOnboarding("account-1"); + requestConnectOnboarding("account-2"); + await vi.runAllTimersAsync(); + expect(navigation.navigate).toHaveBeenCalledOnce(); + }); + + it("drops a request cleared while it waits for navigation to be ready", async () => { + vi.useFakeTimers(); + navigation.ready = false; + requestConnectOnboarding("account-1"); + await vi.runAllTimersAsync(); + clearConnectOnboardingRequest(); + for (const navigate of navigation.queue.splice(0)) navigate(); + expect(navigation.navigate).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/mobile/src/features/cloud/connectOnboarding.ts b/apps/mobile/src/features/cloud/connectOnboarding.ts index c324b700942fe..1be4cee1e201d 100644 --- a/apps/mobile/src/features/cloud/connectOnboarding.ts +++ b/apps/mobile/src/features/cloud/connectOnboarding.ts @@ -1,17 +1,46 @@ -import { Atom } from "effect/reactivity"; +import { rootNavigationRef, whenRootNavigationReady } from "../../lib/rootNavigation"; +import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; -import { appAtomRegistry } from "../../state/atom-registry"; +// Sign-in happens in its own modal; give its session-state transition a beat to +// settle before presenting another formSheet on top. +const PRESENT_ONBOARDING_DELAY_MS = 600; + +interface PendingRequest { + readonly accountId: string; + readonly timer: ReturnType; +} // Retain the account id so sign-out cannot present onboarding for the wrong account. -export const connectOnboardingRequestAtom = Atom.make(null).pipe( - Atom.keepAlive, - Atom.withLabel("mobile:connect-onboarding-request"), -); +let pending: PendingRequest | null = null; export function requestConnectOnboarding(accountId: string): void { - appAtomRegistry.set(connectOnboardingRequestAtom, accountId); + clearConnectOnboardingRequest(); + const request: PendingRequest = { + accountId, + timer: setTimeout(() => { + void (async () => { + const optedOut = await isConnectOnboardingOptedOut(accountId).catch(() => false); + // A sign-out or a newer sign-in may have replaced this request while + // the opt-out read was in flight. + if (pending !== request) return; + if (optedOut) { + pending = null; + return; + } + // Stays pending until navigation is ready, so a sign-out meanwhile still cancels it. + whenRootNavigationReady(() => { + if (pending !== request) return; + pending = null; + rootNavigationRef.navigate("ConnectOnboarding"); + }); + })(); + }, PRESENT_ONBOARDING_DELAY_MS), + }; + pending = request; } export function clearConnectOnboardingRequest(): void { - appAtomRegistry.set(connectOnboardingRequestAtom, null); + if (pending === null) return; + clearTimeout(pending.timer); + pending = null; } diff --git a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts b/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts deleted file mode 100644 index 5176dc797f65b..0000000000000 --- a/apps/mobile/src/features/cloud/connectOnboardingNavigation.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { useAtomValue } from "@effect/atom-react"; -import { useNavigation } from "@react-navigation/native"; -import { useEffect } from "react"; - -import { appAtomRegistry } from "../../state/atom-registry"; -import { clearConnectOnboardingRequest, connectOnboardingRequestAtom } from "./connectOnboarding"; -import { isConnectOnboardingOptedOut } from "./connectOnboardingOptOut"; - -// Sign-in happens in its own modal; give its session-state transition a beat to -// settle before presenting another formSheet on top. -const PRESENT_ONBOARDING_DELAY_MS = 600; - -/** Keep @react-navigation out of non-navigation consumers' import graphs. */ -export function useConnectOnboardingNavigation(): void { - const navigation = useNavigation(); - const requestedAccountId = useAtomValue(connectOnboardingRequestAtom); - - useEffect(() => { - if (requestedAccountId === null) { - return; - } - let cancelled = false; - const timer = setTimeout(() => { - void (async () => { - const optedOut = await isConnectOnboardingOptedOut(requestedAccountId).catch(() => false); - // Re-check the request atom: sign-out can clear it before this render commits. - if (cancelled || appAtomRegistry.get(connectOnboardingRequestAtom) !== requestedAccountId) { - return; - } - clearConnectOnboardingRequest(); - if (!optedOut) { - navigation.navigate("ConnectOnboarding"); - } - })(); - }, PRESENT_ONBOARDING_DELAY_MS); - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [navigation, requestedAccountId]); -} diff --git a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts index 5bf1a52f6e3d8..ac32844cd9383 100644 --- a/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts +++ b/apps/mobile/src/features/cloud/connectOnboardingOptOut.ts @@ -1,6 +1,8 @@ import { loadPreferences, updatePreferences } from "../../persistence/imperative"; -// Keep persistence out of CloudAuthProvider's import graph; it breaks suite loading. +// Lives apart from connectOnboarding.ts so cloudAccountService.ts (which +// imports the request signal) never pulls the persistence adapter into its +// module graph; that breaks CloudAuthProvider.test.ts suite loading. export async function isConnectOnboardingOptedOut(accountId: string): Promise { const preferences = await loadPreferences(); diff --git a/apps/mobile/src/features/home/home-route-visibility.ts b/apps/mobile/src/features/home/home-route-visibility.ts index 98f53a6c3aec0..5413ea1b1b0d4 100644 --- a/apps/mobile/src/features/home/home-route-visibility.ts +++ b/apps/mobile/src/features/home/home-route-visibility.ts @@ -1,41 +1,59 @@ import { RegistryContext } from "@effect/atom-react"; import { useNavigation } from "@react-navigation/native"; -import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; -import { useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useContext, useRef, useSyncExternalStore } from "react"; import type { Atom } from "effect/reactivity"; -/** Fabric never freezes the route below focus; Home must stop its own work beneath Thread. */ +/** + * Home stays mounted under a pushed Thread, and native-stack never freezes the + * route directly below the focused one on Fabric, so Home has to stop its own + * work. The Home screen's static `listeners` (Stack.tsx) drive this store. + */ +let homeVisible: boolean | null = null; +const visibilityListeners = new Set<() => void>(); + +function setHomeVisible(next: boolean) { + if (next === homeVisible) return; + homeVisible = next; + for (const listener of visibilityListeners) listener(); +} + +function subscribeHomeVisibility(onChange: () => void) { + visibilityListeners.add(onChange); + return () => { + visibilityListeners.delete(onChange); + // The value describes one Home instance. A later Home can mount under a + // Thread with no event of its own (Android activity replacement, a cold + // deep link), so it must fall back to `isFocused()` instead of this one. + if (visibilityListeners.size === 0) homeVisible = null; + }; +} + +export function recordHomeTransitionStart(closing: boolean): void { + if (!closing) setHomeVisible(true); +} + +export function recordHomeTransitionEnd(closing: boolean): void { + if (closing) setHomeVisible(false); +} + +/** Popping a sheet or a deep-linked route above Home has no appear event. */ +export function recordHomeFocus(): void { + setHomeVisible(true); +} + export function useHomeRouteVisible(): boolean { - const navigation = - useNavigation>(); - // A cold deep link mounts Home under its Thread without ever showing it. - const [visible, setVisible] = useState(() => navigation.isFocused()); - useEffect(() => { - const show = () => setVisible(true); - const removeTransitionStart = navigation.addListener("transitionStart", ({ data }) => { - if (!data.closing) show(); - }); - const removeTransitionEnd = navigation.addListener("transitionEnd", ({ data }) => { - if (data.closing) setVisible(false); - }); - // Popping a sheet or a deep-linked route above Home has no appear event. - const removeFocus = navigation.addListener("focus", show); - return () => { - removeTransitionStart(); - removeTransitionEnd(); - removeFocus(); - }; - }, [navigation]); - return visible; + const navigation = useNavigation(); + const visible = useSyncExternalStore(subscribeHomeVisibility, () => homeVisible); + // Before Home's first navigation event: a cold deep link mounts Home under + // its Thread without ever showing it. + return visible ?? navigation.isFocused(); } export function useAtomValueWhileVisible
(atom: Atom.Atom, visible: boolean): A { const registry = useContext(RegistryContext); const held = useRef<{ readonly value: A } | null>(null); - const subscribe = useCallback( - (onChange: () => void) => (visible ? registry.subscribe(atom, onChange) : () => undefined), - [registry, atom, visible], - ); + const subscribe = (onChange: () => void) => + visible ? registry.subscribe(atom, onChange) : () => undefined; return useSyncExternalStore(subscribe, () => { if (visible || held.current === null) held.current = { value: registry.get(atom) }; return held.current.value; diff --git a/apps/mobile/src/features/shortcuts/appShortcutsRuntime.test.ts b/apps/mobile/src/features/shortcuts/appShortcutsRuntime.test.ts new file mode 100644 index 0000000000000..c985506480b76 --- /dev/null +++ b/apps/mobile/src/features/shortcuts/appShortcutsRuntime.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("expo-quick-actions", () => ({})); +vi.mock("react-native", () => ({ Platform: { OS: "android" } })); +vi.mock("../../persistence/imperative", () => ({})); +vi.mock("../../state/atom-registry", () => ({})); +vi.mock("../../state/threads", () => ({ environmentThreadShells: {} })); +vi.mock("./appShortcuts", async () => { + const { withRecentThreadShortcut } = + await vi.importActual("./appShortcuts"); + return { withRecentThreadShortcut }; +}); + +import type { RecentThreadShortcut } from "../../persistence/imperative"; +import { createRecentThreadShortcuts } from "./appShortcutsRuntime"; + +const thread = (threadId: string, title = threadId): RecentThreadShortcut => ({ + environmentId: "env", + threadId, + title, +}); + +function setup(load: () => Promise>) { + const save = vi.fn<(list: ReadonlyArray) => Promise>(() => + Promise.resolve(), + ); + const publish = vi.fn<(list: ReadonlyArray) => Promise>(() => + Promise.resolve(), + ); + return { save, publish, recents: createRecentThreadShortcuts({ load, save, publish }) }; +} + +describe("createRecentThreadShortcuts", () => { + it("waits for the persisted list before recording the open thread", async () => { + const { save, publish, recents } = setup(() => Promise.resolve([thread("old")])); + recents.recordActiveThread(thread("new")); + expect(publish).not.toHaveBeenCalled(); + await recents.loaded; + await recents.saved(); + expect(publish).toHaveBeenLastCalledWith([thread("new"), thread("old")]); + expect(save).toHaveBeenLastCalledWith([thread("new"), thread("old")]); + }); + + it("does not persist the empty fallback after a failed load", async () => { + using _warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); + const { save, publish, recents } = setup(() => Promise.reject(new Error("read failed"))); + await recents.loaded; + await recents.saved(); + expect(publish).toHaveBeenLastCalledWith([]); + expect(save).not.toHaveBeenCalled(); + + recents.recordActiveThread(thread("a")); + await recents.saved(); + expect(save).toHaveBeenLastCalledWith([thread("a")]); + }); + + it("records title changes of the open thread and ignores repeats", async () => { + const { publish, recents } = setup(() => Promise.resolve([])); + await recents.loaded; + recents.recordActiveThread(thread("a", "")); + recents.recordActiveThread(thread("a", "Named")); + recents.recordActiveThread(thread("a", "Named")); + recents.recordActiveThread(null); + expect(publish.mock.calls.map(([list]) => list)).toEqual([ + [], + [thread("a", "")], + [thread("a", "Named")], + ]); + }); +}); diff --git a/apps/mobile/src/features/shortcuts/appShortcutsRuntime.ts b/apps/mobile/src/features/shortcuts/appShortcutsRuntime.ts new file mode 100644 index 0000000000000..b182c33b07866 --- /dev/null +++ b/apps/mobile/src/features/shortcuts/appShortcutsRuntime.ts @@ -0,0 +1,149 @@ +import * as QuickActions from "expo-quick-actions"; +import { Platform } from "react-native"; +import type { NavigationState } from "@react-navigation/native"; +import type { ScopedThreadRef } from "@t3tools/contracts"; +import { Atom } from "effect/reactivity"; + +import { + loadRecentThreadShortcuts, + saveRecentThreadShortcuts, + type RecentThreadShortcut, +} from "../../persistence/imperative"; +import { appAtomRegistry } from "../../state/atom-registry"; +import { environmentThreadShells } from "../../state/threads"; +import { + activeThreadRef, + buildShortcutActions, + shortcutHref, + withRecentThreadShortcut, +} from "./appShortcuts"; + +export interface RecentThreadShortcutsDependencies { + readonly load: () => Promise>; + readonly save: (recents: ReadonlyArray) => Promise; + readonly publish: (recents: ReadonlyArray) => Promise; +} + +/** + * Keeps the launcher's recent-thread list. Recording waits for the persisted + * list so the first thread opened after a cold start cannot clobber older + * entries, and a failed load never persists its empty fallback over valid + * history until a real thread open makes the in-memory list the new truth. + */ +export function createRecentThreadShortcuts(dependencies: RecentThreadShortcutsDependencies) { + let recents: ReadonlyArray | null = null; + let persistable = false; + let active: RecentThreadShortcut | null = null; + // Serialize saves so older lists cannot overwrite newer ones. + let saveQueue: Promise = Promise.resolve(); + + const commit = (next: ReadonlyArray) => { + recents = next; + if (persistable) { + saveQueue = saveQueue.then( + () => + dependencies.save(next).catch((error: unknown) => { + console.warn("[app-shortcuts] failed to persist recent threads", error); + }), + () => undefined, + ); + } + void dependencies.publish(next).catch((error: unknown) => { + console.warn("[app-shortcuts] failed to update launcher shortcuts", error); + }); + }; + + const withActive = (list: ReadonlyArray) => { + if (active === null) return list; + const next = withRecentThreadShortcut(list, active); + if (next !== list) persistable = true; + return next; + }; + + const loaded = dependencies.load().then( + (threads) => { + persistable = true; + commit(withActive(threads)); + }, + (error: unknown) => { + console.warn("[app-shortcuts] failed to load recent threads", error); + commit(withActive([])); + }, + ); + + return { + loaded, + /** Resolves once every save issued so far has finished. */ + saved: () => saveQueue, + recordActiveThread(next: RecentThreadShortcut | null): void { + active = next; + if (recents === null || next === null) return; + const updated = withActive(recents); + if (updated !== recents) commit(updated); + }, + }; +} + +const activeThreadRefAtom = Atom.make(null).pipe( + Atom.keepAlive, + Atom.withLabel("mobile:app-shortcuts-active-thread-ref"), +); + +const activeThreadShortcutAtom = Atom.make((get): RecentThreadShortcut | null => { + const ref = get(activeThreadRefAtom); + if (ref === null) return null; + const title = get(environmentThreadShells.threadShellAtom(ref))?.title ?? ""; + return { environmentId: ref.environmentId, threadId: ref.threadId, title }; +}).pipe( + Atom.withEquality( + (left: RecentThreadShortcut | null, right: RecentThreadShortcut | null) => + left === right || + (left !== null && + right !== null && + left.environmentId === right.environmentId && + left.threadId === right.threadId && + left.title === right.title), + ), +); + +/** Wired to the root navigation container's state changes. */ +export function recordAppShortcutNavigationState(state: NavigationState): void { + // Launcher shortcuts are Android-only; iOS never subscribes to thread shells. + if (Platform.OS !== "android") return; + const next = activeThreadRef(state); + const current = appAtomRegistry.get(activeThreadRefAtom); + if (next?.environmentId === current?.environmentId && next?.threadId === current?.threadId) { + return; + } + appAtomRegistry.set(activeThreadRefAtom, next); +} + +/** + * App-lifetime quick actions. `navigate` must queue until navigation is ready: + * a cold-start shortcut arrives as the launch action, and pushing it over the + * initial Home route makes back return home instead of exiting the app. + */ +export function installAppShortcuts(navigate: (href: string) => void): () => void { + const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; + if (initialHref !== null) navigate(initialHref); + const subscription = QuickActions.addListener((action) => { + const href = shortcutHref(action); + if (href !== null) navigate(href); + }); + if (Platform.OS !== "android") return () => subscription.remove(); + + const recents = createRecentThreadShortcuts({ + load: loadRecentThreadShortcuts, + save: saveRecentThreadShortcuts, + publish: (list) => QuickActions.setItems(buildShortcutActions(list)), + }); + const unsubscribe = appAtomRegistry.subscribe( + activeThreadShortcutAtom, + (active) => recents.recordActiveThread(active), + { immediate: true }, + ); + return () => { + subscription.remove(); + unsubscribe(); + }; +} diff --git a/apps/mobile/src/features/shortcuts/useAppShortcuts.ts b/apps/mobile/src/features/shortcuts/useAppShortcuts.ts deleted file mode 100644 index 677dd8110dee9..0000000000000 --- a/apps/mobile/src/features/shortcuts/useAppShortcuts.ts +++ /dev/null @@ -1,125 +0,0 @@ -import * as QuickActions from "expo-quick-actions"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { Platform } from "react-native"; -import { useLinkTo, type NavigationState } from "@react-navigation/native"; - -import { - loadRecentThreadShortcuts, - saveRecentThreadShortcuts, - type RecentThreadShortcut, -} from "../../persistence/imperative"; -import { useThreadShell } from "../../state/entities"; -import { - activeThreadRef, - buildShortcutActions, - shortcutHref, - withRecentThreadShortcut, -} from "./appShortcuts"; - -export function useAppShortcuts(state: NavigationState): void { - useShortcutNavigation(); - useRecentThreadShortcutSync(state); -} - -function useShortcutNavigation(): void { - const linkTo = useLinkTo(); - const handledInitialAction = useRef(false); - - useEffect(() => { - // Handle the launch action before listeners; push over Home so Back returns there. - if (!handledInitialAction.current) { - handledInitialAction.current = true; - const initialHref = QuickActions.initial ? shortcutHref(QuickActions.initial) : null; - if (initialHref !== null) { - linkTo(initialHref); - } - } - - const subscription = QuickActions.addListener((action) => { - const href = shortcutHref(action); - if (href !== null) { - linkTo(href); - } - }); - return () => subscription.remove(); - }, [linkTo]); -} - -function useRecentThreadShortcutSync(state: NavigationState): void { - // iOS subscriptions for Android-only shortcuts would re-render every screen on active-thread updates. - const threadRef = useMemo( - () => (Platform.OS === "android" ? activeThreadRef(state) : null), - [state], - ); - const threadShell = useThreadShell(threadRef); - // Wait for persisted recents before recording visits, or cold starts overwrite older entries. - const [recents, setRecents] = useState | null>(null); - // Do not persist failed-load fallbacks; only a real visit makes the list authoritative. - const persistableRef = useRef(false); - // Serialize saves so older lists cannot overwrite newer ones. - const saveQueueRef = useRef>(Promise.resolve()); - - useEffect(() => { - if (Platform.OS !== "android") { - return; - } - - let cancelled = false; - void loadRecentThreadShortcuts() - .then((threads) => { - if (!cancelled) { - persistableRef.current = true; - setRecents(threads); - } - }) - .catch((error) => { - console.warn("[app-shortcuts] failed to load recent threads", error); - if (!cancelled) { - setRecents([]); - } - }); - return () => { - cancelled = true; - }; - }, []); - - const loaded = recents !== null; - const environmentId = threadRef?.environmentId ?? null; - const threadId = threadRef?.threadId ?? null; - const title = threadShell?.title ?? ""; - useEffect(() => { - if (!loaded || environmentId === null || threadId === null) { - return; - } - - setRecents((current) => { - if (current === null) { - return current; - } - const next = withRecentThreadShortcut(current, { environmentId, threadId, title }); - if (next !== current) { - persistableRef.current = true; - } - return next; - }); - }, [loaded, environmentId, threadId, title]); - - useEffect(() => { - if (recents === null) { - return; - } - - if (persistableRef.current) { - saveQueueRef.current = saveQueueRef.current.then( - () => - saveRecentThreadShortcuts(recents).catch((error) => { - console.warn("[app-shortcuts] failed to persist recent threads", error); - }), - () => undefined, - ); - } - void QuickActions.setItems(buildShortcutActions(recents)).catch((error) => { - console.warn("[app-shortcuts] failed to update launcher shortcuts", error); - }); - }, [recents]); -} diff --git a/apps/mobile/src/lib/rootNavigation.test.ts b/apps/mobile/src/lib/rootNavigation.test.ts index af1fff8c1c7cb..df470fab04e78 100644 --- a/apps/mobile/src/lib/rootNavigation.test.ts +++ b/apps/mobile/src/lib/rootNavigation.test.ts @@ -7,10 +7,21 @@ vi.mock("@react-navigation/native", () => ({ isReady() { return this.current?.isReady() ?? false; }, + dispatched: [] as Array, + dispatch(action: unknown) { + this.dispatched.push(action); + }, + getRootState: () => ({ key: "root" }), }), })); -const { attachRootNavigation, rootNavigationRef } = await import("./rootNavigation"); +const { + attachRootNavigation, + flushRootNavigationQueue, + linkToRootPath, + rootNavigationRef, + whenRootNavigationReady, +} = await import("./rootNavigation"); function fakeContainer() { return { isReady: () => true } as NavigationContainerRef; @@ -29,3 +40,42 @@ it("keeps the newer container when an older root unmounts after it attached", () detachCurrent(); expect(rootNavigationRef.current).toBeNull(); }); + +it("holds navigation until the container is ready, then runs it once", () => { + const navigate = vi.fn(); + const failing = vi.fn(() => { + throw new Error("unknown route"); + }); + const logged = vi.spyOn(console, "error").mockImplementation(() => undefined); + whenRootNavigationReady(failing); + whenRootNavigationReady(navigate); + expect(navigate).not.toHaveBeenCalled(); + + const detach = attachRootNavigation(fakeContainer()); + flushRootNavigationQueue(); + flushRootNavigationQueue(); + expect(failing).toHaveBeenCalledOnce(); + expect(navigate).toHaveBeenCalledOnce(); + expect(logged).toHaveBeenCalledOnce(); + + whenRootNavigationReady(navigate); + expect(navigate).toHaveBeenCalledTimes(2); + detach(); + logged.mockRestore(); +}); + +it("rejects an href without a leading slash, as useLinkTo does", () => { + const toAction = vi.fn(() => ({ type: "NAVIGATE" })); + const detach = attachRootNavigation(fakeContainer()); + const { dispatched } = rootNavigationRef as unknown as { dispatched: Array }; + + expect(() => linkToRootPath("threads/env/t1", toAction)).toThrow( + "The href must start with '/' (threads/env/t1).", + ); + expect(toAction).not.toHaveBeenCalled(); + + linkToRootPath("/threads/env/t1", toAction); + expect(toAction).toHaveBeenCalledExactlyOnceWith("/threads/env/t1"); + expect(dispatched).toEqual([{ target: "root", type: "NAVIGATE" }]); + detach(); +}); diff --git a/apps/mobile/src/lib/rootNavigation.ts b/apps/mobile/src/lib/rootNavigation.ts index 3f064f9301f3a..ec2bd3f5f98dc 100644 --- a/apps/mobile/src/lib/rootNavigation.ts +++ b/apps/mobile/src/lib/rootNavigation.ts @@ -1,11 +1,13 @@ import { createNavigationContainerRef, + type NavigationAction, type NavigationContainerRef, } from "@react-navigation/native"; /** * The app's single navigation container. Work that outlives a screen (a draft - * route's branch checkout) navigates through it. + * route's branch checkout, notification taps, quick actions, Connect + * onboarding) navigates through it instead of mounting hooks in the navigator. */ export const rootNavigationRef = createNavigationContainerRef(); @@ -22,3 +24,39 @@ export function attachRootNavigation( if (rootNavigationRef.current === navigation) rootNavigationRef.current = null; }; } + +const pendingUntilReady: Array<() => void> = []; + +/** Runs `navigate` now if the container is ready, otherwise once it becomes ready. */ +export function whenRootNavigationReady(navigate: () => void): void { + if (rootNavigationRef.isReady()) { + navigate(); + return; + } + pendingUntilReady.push(navigate); +} + +/** Wired to the container's `onReady`. */ +export function flushRootNavigationQueue(): void { + for (const navigate of pendingUntilReady.splice(0)) { + try { + navigate(); + } catch (error) { + console.error("[navigation] queued navigation failed", error); + } + } +} + +/** + * `useLinkTo` for app services that live outside the navigator: the same + * leading-'/' guard, then the href's action dispatched at the root once ready. + */ +export function linkToRootPath(href: string, toAction: (href: string) => NavigationAction): void { + if (!href.startsWith("/")) throw new Error(`The href must start with '/' (${href}).`); + whenRootNavigationReady(() => { + rootNavigationRef.dispatch({ + target: rootNavigationRef.getRootState()?.key, + ...toAction(href), + }); + }); +} diff --git a/scripts/effect-budget.json b/scripts/effect-budget.json index 7a4e4a1e2f25b..50c665e65d647 100644 --- a/scripts/effect-budget.json +++ b/scripts/effect-budget.json @@ -88,7 +88,6 @@ "apps/mobile/src/features/settings/components/BranchNamingSettings.tsx": 1, "apps/mobile/src/features/settings/components/SettingsLegalDocumentRouteScreen.tsx": 1, "apps/mobile/src/features/sharing/IncomingShareProvider.tsx": 4, - "apps/mobile/src/features/shortcuts/useAppShortcuts.ts": 4, "apps/mobile/src/features/showcase/ShowcaseCaptureCoordinator.tsx": 11, "apps/mobile/src/features/terminal/NativeTerminalSurface.tsx": 4, "apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx": 14, From 278fdce1af7eb821c34ee416c441952df250ac63 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 23:38:34 -0700 Subject: [PATCH 018/317] test(server): data regression test now fails when recent fetch savings regress (#25795) Co-authored-by: Claude Opus 5.5 --- .../TransferBudgetReport.integration.ts | 75 ++++++--- .../TransferBudgetV2Fixture.integration.ts | 152 +++++++++++++----- .../transferBudgetV2.integration.test.ts | 100 +++++++++--- docs/internals/performance-regressions.md | 7 +- 4 files changed, 249 insertions(+), 85 deletions(-) diff --git a/apps/server/integration/TransferBudgetReport.integration.ts b/apps/server/integration/TransferBudgetReport.integration.ts index fbe8185a4b482..fd434e2bc26eb 100644 --- a/apps/server/integration/TransferBudgetReport.integration.ts +++ b/apps/server/integration/TransferBudgetReport.integration.ts @@ -11,7 +11,11 @@ import { TRANSFER_MEASURED_MCP_RESULT_BYTES, TRANSFER_MEASURED_TOOLS, } from "./fixtures/transferBudget.ts"; -import { LONG_TURN_WORK_ROWS } from "./TransferBudgetV2Fixture.integration.ts"; +import { + LONG_TURN_WORK_ROWS, + SHELL_IDLE_SUBAGENTS, + SHELL_SETTLED_THREADS, +} from "./TransferBudgetV2Fixture.integration.ts"; export interface WebSocketCatchUpMeasurement extends WebSocketTransferTotals { readonly mode: "replay" | "snapshot"; @@ -36,6 +40,12 @@ export interface TransferBudgetRun { /** The compact bounded snapshot web and mobile load when the long-turn thread opens. */ readonly longTurnColdOpen: TimelineHttpMeasurement; readonly longTurnHistoryPage: TimelineHttpMeasurement; + /** The same reads as an older client that only sends `compactTurnItems`, to show the savings. */ + readonly longTurnColdOpenLegacy: TimelineHttpMeasurement; + readonly longTurnHistoryPageLegacy: TimelineHttpMeasurement; + /** The cold-connect shell snapshot after the settled-thread fleet is written. */ + readonly fleetShell: TimelineHttpMeasurement; + readonly fleetShellLegacy: TimelineHttpMeasurement; } interface ProviderTransferBudget { @@ -46,11 +56,12 @@ interface ProviderTransferBudget { readonly measuredTurnWebSocketMessages: number; } +// The measured turn decodes to about 22 KB; without live body dedupe its repeated answer makes it 26 KB. const TRANSFER_BUDGET = { totalWireBytes: 7_000, threadSnapshotWireBytes: 5_000, measuredTurnWebSocketWireBytes: 2_000, - measuredTurnWebSocketDecodedBytes: 30_000, + measuredTurnWebSocketDecodedBytes: 25_000, measuredTurnWebSocketMessages: 8, } satisfies ProviderTransferBudget; @@ -59,15 +70,20 @@ const TRANSFER_BUDGETS: Readonly> = { claudeAgent: TRANSFER_BUDGET, }; -// Decoded bytes, not wire bytes: phones parse and keep the decoded JSON. About -// 30% above the observed 92 KB; without row budgets inside user turns the cold -// open carried all 920 rows (1.07 MB). Kept apart from TRANSFER_BUDGET because -// the PR comment validates that object's keys. +// Decoded bytes, not wire bytes: phones parse and keep the decoded JSON. About 7% above the +// observed 103 KB, so a cold open without body dedupe (117 KB), the ID table, or row budgets +// (all 920 rows) fails. Kept apart from TRANSFER_BUDGET because the PR comment validates its keys. const LONG_TURN_BUDGET = { - decodedBytes: 120_000, + decodedBytes: 110_000, rows: 75, }; +// About 30% above the observed 42 KB. Without the shell opt-ins it carries all 142 rows (217 KB). +const FLEET_SHELL_BUDGET = { + decodedBytes: 55_000, + rows: 27, +}; + function totalWireBytes(run: TransferBudgetRun): number { return run.threadSnapshot.wireBytes + run.measuredTurnWebSocket.wireBytes; } @@ -160,21 +176,28 @@ function webSocketRows( ]; } -function longTurnRows( +function timelineRows( provider: ProviderDriverKind, phase: string, measured: TimelineHttpMeasurement, + budget: { readonly decodedBytes: number; readonly rows: number }, ): string[] { return [ infoRow(provider, phase, "HTTP wire", measured.http.wireBytes), - row( - provider, - phase, - "HTTP decoded", - measured.http.decodedBodyBytes, - LONG_TURN_BUDGET.decodedBytes, - ), - row(provider, phase, "rows", measured.rows, LONG_TURN_BUDGET.rows, String), + row(provider, phase, "HTTP decoded", measured.http.decodedBodyBytes, budget.decodedBytes), + row(provider, phase, "rows", measured.rows, budget.rows, String), + ]; +} + +function legacyRows( + provider: ProviderDriverKind, + phase: string, + measured: TimelineHttpMeasurement, +): string[] { + return [ + infoRow(provider, `${phase}, older client`, "HTTP wire", measured.http.wireBytes), + infoRow(provider, `${phase}, older client`, "HTTP decoded", measured.http.decodedBodyBytes), + infoRow(provider, `${phase}, older client`, "rows", measured.rows, String), ]; } @@ -216,6 +239,12 @@ export function transferBudgetViolations(runs: ReadonlyArray) LONG_TURN_BUDGET.decodedBytes, ], ["long-turn history page rows", run.longTurnHistoryPage.rows, LONG_TURN_BUDGET.rows], + [ + "fleet shell snapshot decoded bytes", + run.fleetShell.http.decodedBodyBytes, + FLEET_SHELL_BUDGET.decodedBytes, + ], + ["fleet shell snapshot rows", run.fleetShell.rows, FLEET_SHELL_BUDGET.rows], ] as const; for (const [metric, observed, maximum] of checks) { if (observed > maximum) { @@ -234,6 +263,7 @@ export function formatTransferBudgetReport(runs: ReadonlyArray; export function threadCreated( provider: ProviderDriverKind, threadId: ThreadId = THREAD_ID, -): OrchestrationV2DomainEvent { + overrides: Partial = {}, +): ThreadCreatedEvent { const now = DateTime.makeUnsafe("2026-06-01T00:00:00Z"); return { id: EventId.make(`thread-created:${threadId}`), @@ -58,6 +62,7 @@ export function threadCreated( settledAt: null, lastVisitedAt: null, deletedAt: null, + ...overrides, }, }; } @@ -102,6 +107,9 @@ export function turnEvents( const events: OrchestrationV2DomainEvent[] = [ { ...common, id: EventId.make(`run-${index}`), type: "run.created", payload: run }, ]; + const answer = + `Completed ${provider} inspection ${index}. ` + + "The projection preserves transcript text and bounds tool output. ".repeat(60); for (const role of ["user", "assistant"] as const) { events.push({ ...common, @@ -113,11 +121,7 @@ export function turnEvents( runId, nodeId, role, - text: - role === "user" - ? `Inspect transfer ${index}` - : `Completed ${provider} inspection ${index}. ` + - "The projection preserves transcript text and bounds tool output. ".repeat(60), + text: role === "user" ? `Inspect transfer ${index}` : answer, attachments: [], streaming: false, createdAt: now, @@ -128,6 +132,22 @@ export function turnEvents( }); } const toolCount = measured ? TRANSFER_MEASURED_TOOLS : TRANSFER_HISTORY_TOOLS_PER_TURN; + const item = (id: string, ordinal: number, title: string | null) => ({ + id: TurnItemId.make(id), + threadId: THREAD_ID, + runId, + nodeId, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + status: "completed" as const, + title, + startedAt: now, + completedAt: now, + updatedAt: now, + }); for (let tool = 0; tool <= toolCount; tool++) { const mcp = tool === toolCount; const output = diagnosticOutput({ @@ -140,22 +160,11 @@ export function turnEvents( : TRANSFER_HISTORY_MCP_RESULT_BYTES : 4096, }); - const base = { - id: TurnItemId.make(`item-${index}-${tool}`), - threadId: THREAD_ID, - runId, - nodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, - parentItemId: null, - ordinal: tool + 1, - status: "completed" as const, - title: mcp ? "MCP search" : `Inspect source ${tool}`, - startedAt: now, - completedAt: now, - updatedAt: now, - }; + const base = item( + `item-${index}-${tool}`, + tool + 1, + mcp ? "MCP search" : `Inspect source ${tool}`, + ); events.push({ ...common, id: EventId.make(`tool-${index}-${tool}`), @@ -177,6 +186,21 @@ export function turnEvents( }, }); } + // The live turn repeats its answer in a turn item, as adapters do, so live dedupe has work. + // History turns skip it to keep the fallback thread snapshot under its wire ceiling. + if (measured) + events.push({ + ...common, + id: EventId.make(`answer-item-${index}`), + type: "turn-item.updated", + payload: { + ...item(`item-${index}-answer`, toolCount + 2, null), + type: "assistant_message", + messageId: MessageId.make(`assistant-${index}`), + text: answer, + streaming: false, + }, + }); events.push({ ...common, id: EventId.make(`complete-${index}`), @@ -192,6 +216,8 @@ export const LONG_TURN_WORK_ROWS = 900; /** * Three short user turns, then one agent turn far longer than a cold-open * window, with the item kinds a long agent run produces. One batch per turn. + * Run, node, provider and item IDs follow the composite shapes the Claude + * adapter stores, and every assistant message has its record, as real threads do. */ export function longTurnThreadBatches( provider: ProviderDriverKind, @@ -208,8 +234,14 @@ export function longTurnThreadBatches( const turn = (index: number, workRows: number): OrchestrationV2DomainEvent[] => { const now = DateTime.makeUnsafe(`2026-06-02T00:${String(index).padStart(2, "0")}:00Z`); - const runId = RunId.make(`long-turn-run-${index}`); - const nodeId = NodeId.make(`long-turn-node-${index}`); + const runId = RunId.make(`run:thread:${threadId}:ordinal:${index + 1}`); + const nodeId = NodeId.make(`node:run:${encodeURIComponent(runId)}:root`); + const providerThreadId = ProviderThreadId.make( + `provider-thread:provider:${provider}:native-thread:${encodeURIComponent(`pending:${runId}`)}`, + ); + const providerTurnId = ProviderTurnId.make( + `provider-turn:provider:${provider}:native-turn:${encodeURIComponent(`turn:run-attempt:${encodeURIComponent(runId)}:attempt:1`)}`, + ); const common = { threadId, runId, @@ -218,6 +250,10 @@ export function longTurnThreadBatches( providerInstanceId, occurredAt: now, }; + const messageId = (role: "user" | "assistant", row: string) => + MessageId.make( + `message:provider:${provider}:native-item:msg_01LongTurn${role}${index}x${row}`, + ); const run = { id: runId, threadId, @@ -225,7 +261,7 @@ export function longTurnThreadBatches( providerInstanceId, modelSelection, providerThreadId: null, - userMessageId: MessageId.make(`long-turn-user-${index}`), + userMessageId: messageId("user", "prompt"), rootNodeId: nodeId, activeAttemptId: null, status: "running" as const, @@ -237,14 +273,15 @@ export function longTurnThreadBatches( }; const base = (row: string, title: string | null) => { ordinal += 1; + const nativeId = `toolu_01LongTurn${index}x${row}`; return { - id: TurnItemId.make(`long-turn-item-${index}-${row}`), + id: TurnItemId.make(`turn-item:provider:${provider}:native-item:${nativeId}`), threadId, runId, nodeId, - providerThreadId: null, - providerTurnId: null, - nativeItemRef: null, + providerThreadId, + providerTurnId, + nativeItemRef: { driver: provider, nativeId, strength: "strong" as const }, parentItemId: null, ordinal, status: "completed" as const, @@ -258,7 +295,7 @@ export function longTurnThreadBatches( { ...common, id: EventId.make(`long-turn-run-${index}`), type: "run.created", payload: run }, ]; const message = (role: "user" | "assistant", row: string, messageText: string) => { - const messageId = MessageId.make(`long-turn-${role}-${index}-${row}`); + const id = messageId(role, row); const createdBy = role === "user" ? ("user" as const) : ("agent" as const); events.push( { @@ -266,7 +303,7 @@ export function longTurnThreadBatches( id: EventId.make(`long-turn-message-${index}-${row}`), type: "message.updated", payload: { - id: messageId, + id, threadId, runId, nodeId, @@ -291,7 +328,7 @@ export function longTurnThreadBatches( type: "user_message", createdBy, creationSource: "web", - messageId, + messageId: id, inputIntent: "turn_start", text: messageText, attachments: [], @@ -299,7 +336,7 @@ export function longTurnThreadBatches( : { ...base(row, null), type: "assistant_message", - messageId, + messageId: id, text: messageText, streaming: false, }, @@ -360,14 +397,6 @@ export function longTurnThreadBatches( input: { query: `module ${row}` }, output: { text: text(index, row, 6_144) }, }; - case 5: - return { - ...base(String(row), null), - type: "assistant_message", - messageId: MessageId.make(`long-turn-progress-${index}-${row}`), - text: text(index, row, 600), - streaming: false, - }; default: return { ...base(String(row), `Run check ${row}`), @@ -381,6 +410,10 @@ export function longTurnThreadBatches( message("user", "prompt", `Continue the feature work, step ${index}.`); for (let row = 0; row < workRows; row++) { + if (row % 8 === 5) { + message("assistant", String(row), text(index, row, 1_500)); + continue; + } events.push({ ...common, id: EventId.make(`long-turn-work-${index}-${row}`), @@ -406,3 +439,40 @@ export function longTurnThreadBatches( turn(3, LONG_TURN_WORK_ROWS), ]; } + +export const SHELL_SETTLED_THREADS = 100; +export const SHELL_IDLE_SUBAGENTS = 40; + +/** + * A busy sidebar: settled top-level threads, settled a minute apart, with idle subagent children + * under the newest ones. Opted-in cold connects skip the children and all but 25 settled threads. + */ +export function shellFleetEvents( + provider: ProviderDriverKind, +): ReadonlyArray { + return Array.from({ length: SHELL_SETTLED_THREADS }, (_, index) => { + const threadId = ThreadId.make(`transfer-budget-settled-${index}`); + const settledAt = DateTime.makeUnsafe(Date.UTC(2026, 5, 3, 0, index)); + const settled = threadCreated(provider, threadId, { + title: `Settled transfer thread ${index}`, + branch: `feature/settled-${index}`, + settledOverride: "settled", + settledAt, + updatedAt: settledAt, + }); + if (index < SHELL_SETTLED_THREADS - SHELL_IDLE_SUBAGENTS) return [settled]; + const childId = ThreadId.make(`transfer-budget-settled-${index}-subagent`); + return [ + settled, + threadCreated(provider, childId, { + title: `Subagent of settled thread ${index}`, + createdBy: "agent", + lineage: { + parentThreadId: threadId, + relationshipToParent: "subagent", + rootThreadId: threadId, + }, + }), + ]; + }).flat(); +} diff --git a/apps/server/integration/transferBudgetV2.integration.test.ts b/apps/server/integration/transferBudgetV2.integration.test.ts index 8417d852acfdf..22db0e643db9b 100644 --- a/apps/server/integration/transferBudgetV2.integration.test.ts +++ b/apps/server/integration/transferBudgetV2.integration.test.ts @@ -55,6 +55,7 @@ import { subscribeOrchestrationV2Thread, subscribeOrchestrationV2Shell } from ". import { measureHttpGet, openMeasuredWsClient, + type HttpTransferMeasurement, type MeasuredWsClient, } from "./NetworkTransferMeasurement.integration.ts"; import { makeSqlStatementCounter } from "./SqlStatementCounter.integration.ts"; @@ -68,11 +69,20 @@ import { TRANSFER_HISTORY_TURN_COUNT } from "./fixtures/transferBudget.ts"; import { LONG_TURN_THREAD_ID, longTurnThreadBatches, + SHELL_IDLE_SUBAGENTS, + SHELL_SETTLED_THREADS, + shellFleetEvents, THREAD_ID, threadCreated, turnEvents, } from "./TransferBudgetV2Fixture.integration.ts"; +// The opt-ins current web and mobile clients send; keep in sync with client-runtime's +// boundedThreadSnapshotHttp.ts, threadHistoryHttp.ts, shellSnapshotHttp.ts, threads.ts and shell.ts. +const CLIENT_BOUNDED_QUERY = "compactTurnItems=1&dedupeBodies=1&compactIds=1"; +const CLIENT_HISTORY_QUERY = "compactIds=1"; +const CLIENT_SHELL_QUERY = "omitIdleSubagents=1&omitOlderSettled=1"; + const decodeThreadSnapshot = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.toCodecJson(OrchestrationV2ThreadDetailSnapshot)), ); @@ -220,10 +230,15 @@ const subscribe = Effect.fn("transfer.subscribe")(function* ( afterSequence, requestCompletionMarker: true, acceptBoundedSnapshot: true, + acceptCompactTurnItems: true, + acceptCompactIds: true, + acceptDedupedBodies: true, }) : client.client[ORCHESTRATION_V2_WS_METHODS.subscribeShell]({ afterSequence, requestCompletionMarker: true, + omitIdleSubagents: true, + omitOlderSettled: true, }); yield* stream.pipe( Stream.runForEach((item) => Queue.offer(queue, item)), @@ -323,7 +338,18 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => const finalShell = yield* projection.getThreadShell(THREAD_ID); const shellTerminal = (item: StreamItem) => item.kind === "thread.updated" && NodeUtil.isDeepStrictEqual(item.thread, finalShell); - for (const queue of [tq, ttq]) yield* collectUntil(queue, threadTerminal); + const liveTurn = yield* collectUntil(tq, threadTerminal); + yield* collectUntil(ttq, threadTerminal); + // A direct check, so live dedupe stays guarded however small the turn gets. + const liveAnswer = liveTurn.flatMap((item) => + item.kind === "event" && + item.event.type === "turn-item.updated" && + item.event.payload.type === "assistant_message" + ? [item.event.payload] + : [], + ); + assert.equal(liveAnswer.length, 1); + assert.equal(liveAnswer[0]!.textOmitted, true); for (const queue of [sq, ssq]) yield* collectUntil(queue, shellTerminal); const measuredTurnWebSocket = difference(thread.recorder.totals(), threadBefore); const measuredTurnShellWebSocket = difference(shell.recorder.totals(), shellBefore); @@ -353,25 +379,52 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => assert.equal(threadMode, "snapshot"); for (const events of longTurnThreadBatches(provider)) yield* sink.write({ events }); - const longTurnColdOpen = yield* measureHttpGet({ - url: `${base}/api/orchestration/threads/${LONG_TURN_THREAD_ID}/bounded?compactTurnItems=1`, - headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, - }); - assert.equal(longTurnColdOpen.status, 200); - const coldOpen = yield* decodeBoundedSnapshot( - Buffer.from(longTurnColdOpen.decodedBody).toString(), - ); - const cursor = coldOpen.historyCursor; + const get = (path: string) => + measureHttpGet({ + url: `${base}${path}`, + headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, + }).pipe(Effect.tap((http) => Effect.sync(() => assert.equal(http.status, 200)))); + const decoded = (http: HttpTransferMeasurement) => + Buffer.from(http.decodedBody).toString(); + const longTurnPath = `/api/orchestration/threads/${LONG_TURN_THREAD_ID}`; + const readColdOpen = (query: string) => + Effect.gen(function* () { + const http = yield* get(`${longTurnPath}/bounded?${query}`); + const snapshot = yield* decodeBoundedSnapshot(decoded(http)); + const rows = snapshot.projection.visibleTurnItems.length; + return { http, rows, cursor: snapshot.historyCursor }; + }); + const readHistoryPage = (cursor: string, query: string) => + Effect.gen(function* () { + const http = yield* get( + `${longTurnPath}/history?cursor=${encodeURIComponent(cursor)}&${query}`, + ); + const page = yield* decodeHistoryPage(decoded(http)); + assert.isTrue(page.hasMoreHistory); + return { http, rows: page.items.length }; + }); + const readShell = (query: string) => + Effect.gen(function* () { + const http = yield* get(`/api/orchestration/shell?${query}`); + return { http, rows: (yield* decodeShellSnapshot(decoded(http))).threads.length }; + }); + + const { cursor: _, ...longTurnColdOpenLegacy } = + yield* readColdOpen("compactTurnItems=1"); + const { cursor, ...longTurnColdOpen } = yield* readColdOpen(CLIENT_BOUNDED_QUERY); if (cursor === null) return yield* Effect.die("Expected older long-turn history"); - const longTurnHistoryPage = yield* measureHttpGet({ - url: `${base}/api/orchestration/threads/${LONG_TURN_THREAD_ID}/history?cursor=${encodeURIComponent(cursor)}`, - headers: { [ORCHESTRATION_PROTOCOL_HEADER]: String(ORCHESTRATION_PROTOCOL_VERSION) }, - }); - assert.equal(longTurnHistoryPage.status, 200); - const historyPage = yield* decodeHistoryPage( - Buffer.from(longTurnHistoryPage.decodedBody).toString(), + const longTurnHistoryPageLegacy = yield* readHistoryPage(cursor, ""); + const longTurnHistoryPage = yield* readHistoryPage(cursor, CLIENT_HISTORY_QUERY); + + // The transfer thread plus the long-turn thread. + const earlierThreads = decodedShell.threads.length + 1; + yield* sink.write({ events: shellFleetEvents(provider) }); + const fleetShellLegacy = yield* readShell(""); + const fleetShell = yield* readShell(CLIENT_SHELL_QUERY); + assert.equal( + fleetShellLegacy.rows, + earlierThreads + SHELL_SETTLED_THREADS + SHELL_IDLE_SUBAGENTS, ); - assert.isTrue(historyPage.hasMoreHistory); return { provider, threadSnapshot, @@ -383,11 +436,12 @@ it.live("reports V2 thread HTTP and WebSocket transfer budgets", () => reconnectThread, reconnectShell, reconnectSqlStatements, - longTurnColdOpen: { - http: longTurnColdOpen, - rows: coldOpen.projection.visibleTurnItems.length, - }, - longTurnHistoryPage: { http: longTurnHistoryPage, rows: historyPage.items.length }, + longTurnColdOpenLegacy, + longTurnColdOpen, + longTurnHistoryPageLegacy, + longTurnHistoryPage, + fleetShellLegacy, + fleetShell, } satisfies TransferBudgetRun; }).pipe( Effect.provide(Layer.fresh(layerServices).pipe(Layer.provideMerge(NodeServices.layer))), diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 1a9dfee13b82b..51760756c1085 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -63,9 +63,10 @@ for the same synthetic workload, preserving historical application-object counts These are neither compressed WebSocket captures nor measurements of server RSS, and command fixtures exclude unrelated settings calls and subscription events. Payload budgets belong in these tests so regressions fail locally. The transfer budget integration test -(`apps/server/integration/transferBudgetV2.integration.test.ts`) caps the decoded bytes and rows -of the compact bounded snapshot that web and mobile load, and of one older page, for a 900-row -agent turn. +(`apps/server/integration/transferBudgetV2.integration.test.ts`) sends the opt-ins current clients +send and caps the decoded bytes and rows of the bounded snapshot and one older page for a 900-row +agent turn, and of the cold-connect shell for a sidebar of settled threads and idle subagents. When +clients gain an opt-in, add it to the test's client queries and give a fixture the data it saves. ## Event store and startup From bc813670d9c428fa42707a0d78c01e03177016ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Mon, 26 Oct 2026 23:59:51 -0700 Subject: [PATCH 019/317] fix(server): all MCP tools now send compact schemas, cutting tool context by 23% (#25790) Co-authored-by: Claude Opus 5.5 --- apps/server/src/mcp/McpHttpServer.test.ts | 14 ++++++++ apps/server/src/mcp/McpHttpServer.ts | 33 ++++++++++++++++++- .../src/provider/agentContextBudget.json | 2 +- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 6c502c1ef1305..8d7fb85a9e9b8 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -21,6 +21,7 @@ import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/http import * as ProjectService from "../project/ProjectService.ts"; import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts"; import * as ServerConfig from "../config.ts"; +import { advertisedJsonSchema } from "./advertisedJsonSchema.ts"; import * as McpHttpServer from "./McpHttpServer.ts"; import * as McpToolAccess from "./McpToolAccess.ts"; import * as McpToolAccessTestkit from "./McpToolAccess.testkit.ts"; @@ -29,6 +30,7 @@ import * as McpSessionRegistry from "./McpSessionRegistry.ts"; import * as OrchestratorMcpService from "./OrchestratorMcpService.ts"; import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; import { OrchestratorToolkit } from "./toolkits/orchestrator/tools.ts"; +import { PreviewToolkit } from "./toolkits/preview/tools.ts"; import { ProjectToolkit } from "./toolkits/project/tools.ts"; const environmentId = EnvironmentId.make("environment-mcp-test"); @@ -356,6 +358,18 @@ it.effect("refuses preview tools to a client outside a thread before they run", }).pipe(Effect.provide(layerTest)), ); +it.effect("advertises the compact input schema for every registered tool", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const tools: Record = PreviewToolkit.tools; + + expect(server.tools.length).toBe(Object.keys(tools).length); + for (const { tool } of server.tools) { + expect(tool.inputSchema, tool.name).toEqual(advertisedJsonSchema(tools[tool.name]!)); + } + }).pipe(Effect.provide(layerTest)), +); + it.effect("checks each preview_act action against that action's own input", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 044a43baeab1e..0d8837f0ab875 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -948,6 +948,35 @@ const withToolAnalytics = return yield* registration.pipe(Effect.provideService(McpServer.McpServer, recordingServer)); }); +/** `McpServer.registerToolkit` advertises the full codec schema; agents get the compact one. */ +const withAdvertisedSchemas = Effect.fn("McpHttpServer.withAdvertisedSchemas")(function* < + Tools extends Record, + A, + E, + R, +>(toolkit: Toolkit.Toolkit, registration: Effect.Effect) { + const server = yield* McpServer.McpServer; + const tools: Record = toolkit.tools; + const advertisingServer = McpServer.McpServer.of({ + ...server, + addTool: Effect.fn("McpServer.addTool")(function (options) { + const tool = tools[options.tool.name]; + return server.addTool( + tool === undefined + ? options + : { + ...options, + tool: new McpSchema.Tool({ + ...options.tool, + inputSchema: advertisedJsonSchema(tool), + }), + }, + ); + }), + }); + return yield* registration.pipe(Effect.provideService(McpServer.McpServer, advertisingServer)); +}); + /** * `McpServer.toolkit` asks for every service its tools declare when it * registers them, but the auth middleware provides `McpInvocationContext` to @@ -959,7 +988,9 @@ export const toolkitRegistration = , EX, handlers: McpToolAccess.HandlersLayer, ) => { const registration = Layer.effectDiscard( - withToolAnalytics(McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + withToolAnalytics( + withAdvertisedSchemas(toolkit, McpServer.registerToolkit(decodeAsAdvertised(toolkit))), + ), ).pipe(Layer.provide(McpServer.McpServer.layer)); // @effect-diagnostics-next-line unsafeEffectTypeAssertion:off - the auth middleware provides it per request. const registered = registration as Layer.Layer< diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e3272bfdf8021..a54335979eb4c 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 4857, "codexAdditionalContextBytes": 5409, - "mcpToolListJsonBytes": 54964 + "mcpToolListJsonBytes": 42328 } From 96fc7b955196637605cbf3b415152ce4dd0e2294 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 00:00:18 -0700 Subject: [PATCH 020/317] refactor(web): the new design is the only design, with no toggle (#25778) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/window/DesktopWindow.ts | 2 +- apps/web/index.html | 10 +- apps/web/src/appInit.tsx | 3 - apps/web/src/components/ChatView.tsx | 2 - .../components/ComposerPromptEditorTiptap.tsx | 2 +- apps/web/src/components/DiffPanel.tsx | 18 +-- .../src/components/DiffWorkerPoolProvider.tsx | 10 +- apps/web/src/components/GitActionsControl.tsx | 3 - apps/web/src/components/Sidebar.tsx | 10 +- .../src/components/ThreadStatusIndicators.tsx | 2 +- apps/web/src/components/chat/ChatCanvas.tsx | 3 - apps/web/src/components/chat/ChatComposer.tsx | 2 +- .../chat/ComposerPrimaryActions.tsx | 29 +--- apps/web/src/components/chat/ModelListRow.tsx | 15 +- .../components/chat/ModelPickerContent.tsx | 19 +-- .../components/chat/ModelPickerSidebar.tsx | 7 +- .../components/chat/ThreadDetailsControl.tsx | 11 +- .../components/chat/ThreadDetailsPrRow.tsx | 2 +- .../components/chat/ThreadDetailsPrRows.tsx | 8 +- .../chat/ThreadRelationshipsControl.tsx | 2 +- .../src/components/chat/ThreadStatusLine.tsx | 9 +- apps/web/src/components/chat/TraitsPicker.tsx | 8 +- .../src/components/chat/canvasStore.test.ts | 3 - apps/web/src/components/chat/canvasStore.ts | 3 - .../components/chat/chatCanvasLayout.test.ts | 26 ++-- .../src/components/chat/chatCanvasLayout.ts | 22 ++- .../pullRequest/PullRequestGhosts.tsx | 4 +- .../settings/AppearanceSettingsPanel.tsx | 21 --- .../settings/ProviderInstanceCard.tsx | 9 +- .../settings/ProviderModelsSection.tsx | 2 +- .../settings/ProviderSettingsPanel.tsx | 6 +- .../settings/SettingsFontPreviews.tsx | 2 +- .../components/settings/SettingsPanels.tsx | 4 - .../settings/ThemePreviewCircles.tsx | 2 +- .../src/components/settings/ThemeSettings.tsx | 12 +- .../src/components/settings/settingsSearch.ts | 6 - .../components/sidebar/SidebarListMarkers.tsx | 13 +- .../sidebar/SidebarThreadHeader.tsx | 2 +- .../components/sidebar/SidebarThreadRow.tsx | 132 ++++++++---------- apps/web/src/components/ui/button.tsx | 2 +- .../ui/collapsible-section-header.tsx | 14 +- apps/web/src/components/ui/combobox.tsx | 2 +- apps/web/src/components/ui/command.tsx | 2 +- apps/web/src/components/ui/kbd.tsx | 2 +- .../components/usage/UsageLimitsPooled.tsx | 15 +- apps/web/src/components/usage/UsagePage.tsx | 94 ++++++------- apps/web/src/index.css | 87 +++++------- apps/web/src/lib/diffRendering.ts | 27 +--- apps/web/src/lib/incrementalHighlighting.ts | 7 +- apps/web/src/lib/syntaxHighlighting.ts | 2 +- packages/contracts/src/settings.ts | 3 - 51 files changed, 248 insertions(+), 455 deletions(-) diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 6270f80a4a7e6..20b43f91e63af 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -131,7 +131,7 @@ function getIconOption( } function getInitialWindowBackgroundColor(shouldUseDarkColors: boolean): string { - return shouldUseDarkColors ? "#0a0a0a" : "#ffffff"; + return shouldUseDarkColors ? "#000000" : "#ffffff"; } type DisplayBounds = Pick; diff --git a/apps/web/index.html b/apps/web/index.html index 13518deb6e5d1..a7a5e7450b3de 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,14 +6,14 @@ name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" /> - + "); + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + const run = (yield* orchestrator.getThreadRecords(threadId, ["runs"])).runs[0]; + const item = ( + id: string, + ordinal: number, + fields: + | { readonly type: "assistant_message"; readonly text: string } + | { readonly type: "user_message"; readonly text: string }, + target: { readonly threadId: ThreadId; readonly runId: RunId } = { threadId, runId }, + ) => ({ + id: EventId.make(`event:${id}`), + type: "turn-item.updated" as const, + threadId: target.threadId, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(id), + threadId: target.threadId, + runId: target.runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal, + title: null, + status: "completed" as const, + startedAt: now, + completedAt: now, + updatedAt: now, + messageId: MessageId.make(id), + ...(fields.type === "assistant_message" + ? { ...fields, streaming: false } + : { + ...fields, + createdBy: "user" as const, + creationSource: "web" as const, + inputIntent: "turn_start" as const, + attachments: [], + }), + }, + }); + yield* eventSink.write({ + events: [ + item("item:agent-images", 1, { + type: "assistant_message", + text: `Done: ![Shot](<${shot}>) ![Other](file://${encodeURI(fileUrl)}) ![Page](${page})`, + }), + // A guest's sent message is a user message; it must not open files on the host. + item("item:guest-image", 2, { type: "user_message", text: `![Mine](${fromUser})` }), + { + id: EventId.make("event:run-done"), + type: "run.updated" as const, + threadId, + runId, + providerInstanceId: instanceId, + occurredAt: now, + payload: { ...run!, status: "completed" as const, completedAt: now }, + }, + ], + }); + // The guest watches a fork, which shows these replies as inherited history. + const forkId = ThreadId.make("thread:guest-share-fork"); + yield* orchestrator.dispatch({ + type: "thread.fork", + commandId: CommandId.make("fork"), + sourceThreadId: threadId, + targetThreadId: forkId, + sourcePoint: { type: "run", runId }, + createdBy: "user", + creationSource: "web", + }); + const { url } = yield* guestShares.create({ threadId: forkId }); + const { token: session } = yield* guestShares.redeem({ + credential: url.slice(url.indexOf("#") + 1), + name: "Sam", + }); + + // The guest's page opens, and its stream sends the replies the fork inherits. + const sent = yield* Queue.unbounded(); + yield* Stream.runForEach(yield* guestShares.stream(session), (event) => + Queue.offer(sent, event), + ).pipe(Effect.forkScoped); + const sentItem = Effect.fn(function* (id: string) { + for (;;) { + const event = yield* Queue.take(sent); + if ( + event.type === "thread" && + event.item.kind === "event" && + event.item.event.type === "turn-item.updated" && + event.item.event.payload.id === id + ) { + return; + } + } + }); + + // The agent shows another screenshot live, then enough rows to push the first reply out of + // the window, before the page asks for any image. + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("fork-message"), + threadId: forkId, + messageId: MessageId.make("message:fork"), + text: "One more", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const forkRun = (yield* orchestrator.getThreadRecords(forkId, ["runs"])).runs.at(-1)!; + const inFork = { threadId: forkId, runId: forkRun.id }; + yield* eventSink.write({ + events: [ + item( + "item:later-image", + 1, + { type: "assistant_message", text: `![Later](${later})` }, + inFork, + ), + ...Array.from({ length: 600 }, (_, index) => + item( + `item:filler-${index}`, + index + 2, + { type: "assistant_message", text: "Working." }, + inFork, + ), + ), + ], + }); + yield* sentItem("item:later-image"); + + // The page still shows every reply it was sent, so each image loads. + for (const path of [shot, fileUrl, later]) { + assert.deepStrictEqual(yield* guestShares.media(session, path), { + path, + mimeType: "image/png", + }); + } + for (const path of [page, fromUser, unnamed, `${directory}/missing.png`]) { + assert.equal((yield* Effect.flip(guestShares.media(session, path))).reason, "unavailable"); + } + + // A reply edited to drop its image stops allowing it. + yield* eventSink.write({ + events: [ + { + ...item("item:later-image", 1, { type: "assistant_message", text: "Gone." }, inFork), + id: EventId.make("event:later-image-edited"), + }, + ], + }); + yield* sentItem("item:later-image"); + assert.equal((yield* Effect.flip(guestShares.media(session, later))).reason, "unavailable"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), +); + +it.effect("keeps a connected guest's images while other shares come and go", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const shot = `${directory}/shot.png`; + yield* fileSystem.writeFile(shot, PNG); + const eventSink = yield* EventSink.EventSinkV2; + const now = yield* DateTime.now; + /** A shared thread whose agent reply shows the screenshot, and its guest's session. */ + const sharedWithImage = Effect.fn(function* (id: ThreadId) { + const { orchestrator, guestShares, credential } = yield* shareThreadFor(id); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(`message:${id}`), + threadId: id, + messageId: MessageId.make(`message:${id}`), + text: "Show me", + attachments: [], + dispatchMode: { type: "defer_start" }, + createdBy: "user", + creationSource: "web", + }); + const runId = (yield* orchestrator.getThreadRecords(id, ["runs"])).runs[0]!.id; + yield* eventSink.write({ + events: [ + { + id: EventId.make(`event:reply:${id}`), + type: "turn-item.updated", + threadId: id, + driver: ProviderDriverKind.make("codex"), + providerInstanceId: instanceId, + occurredAt: now, + payload: { + id: TurnItemId.make(`item:reply:${id}`), + threadId: id, + runId, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + title: null, + status: "completed", + startedAt: now, + completedAt: now, + updatedAt: now, + type: "assistant_message", + messageId: MessageId.make(`item:reply:${id}`), + text: `![Shot](${shot})`, + streaming: false, + }, + }, + ], + }); + const { token } = yield* guestShares.redeem({ credential, name: "Sam" }); + return { guestShares, token }; + }); + + // This guest's page stays open while more guests than the record keeps open and close theirs. + const { guestShares, token } = yield* sharedWithImage(threadId); + const opened = yield* Deferred.make(); + yield* Stream.runForEach(yield* guestShares.stream(token), () => + Deferred.succeed(opened, undefined), + ).pipe(Effect.forkScoped); + yield* Deferred.await(opened); + const closedTokens: Array = []; + for (let index = 0; index < 65; index++) { + const other = yield* sharedWithImage(ThreadId.make(`thread:other-${index}`)); + const events = yield* guestShares.stream(other.token); + // The first is built but never read, as for a HEAD request; the rest open and close. + if (index > 0) yield* Stream.runDrain(Stream.take(events, 2)); + closedTokens.push(other.token); + } + assert.equal((yield* guestShares.media(token, shot)).mimeType, "image/png"); + // Records of guests with no open page were dropped to make room. + for (const closed of closedTokens.slice(0, 2)) { + assert.equal((yield* Effect.flip(guestShares.media(closed, shot))).reason, "unavailable"); + } + + // A new link on the thread starts empty: its guest gets nothing the last guest was sent. + const { url } = yield* guestShares.create({ threadId }); + const next = yield* guestShares.redeem({ + credential: url.slice(url.indexOf("#") + 1), + name: "Kim", + }); + assert.equal((yield* Effect.flip(guestShares.media(next.token, shot))).reason, "unavailable"); + yield* Stream.runDrain(Stream.take(yield* guestShares.stream(next.token), 2)); + assert.equal((yield* guestShares.media(next.token, shot)).mimeType, "image/png"); + }).pipe(Effect.scoped, Effect.provide(layerTest)), +); diff --git a/apps/server/src/guest/GuestShareService.ts b/apps/server/src/guest/GuestShareService.ts index 1f65217e45212..0346068bc546f 100644 --- a/apps/server/src/guest/GuestShareService.ts +++ b/apps/server/src/guest/GuestShareService.ts @@ -15,9 +15,11 @@ import { type OrchestrationV2CommandOutputFrame, type OrchestrationV2DomainEvent, type OrchestrationV2GetTurnItemResult, + type OrchestrationV2TurnItem, ThreadId, TurnItemId, } from "@t3tools/contracts"; +import { chatMarkdownImageSources } from "@t3tools/shared/markdownPipeline"; import { htmlRenderFromToolItem } from "@t3tools/shared/toolOutput"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -63,7 +65,10 @@ import { guestShareView, guestThreadModel } from "./guestView.ts"; import { classifyGuestUploads, GUEST_UPLOAD_HEADER_BYTES, + guestHostImageType, type GuestUpload, + matchesImageSignature, + repliesShowImage, } from "./guestAttachments.ts"; const SIGNING_SECRET_NAME = "guest-share-signing-key"; @@ -71,6 +76,8 @@ const SHARE_ID_BYTES = 16; const INVITE_SIGNATURE_BYTES = 16; const SNAPSHOT_ROW_LIMIT = 600; const SNAPSHOT_USER_TURN_LIMIT = 20; +// Shares with no open guest stream whose sent image replies are still kept. +const SHARED_THREAD_REPLIES_LIMIT = 64; // How soon an open read notices that its link stopped working, such as Connect no longer serving // the host. Closing or replacing the link ends the main stream at once. const ACCESS_RECHECK_INTERVAL = Duration.seconds(30); @@ -190,9 +197,20 @@ export class GuestShareService extends Context.Service< { readonly path: string; readonly attachment: ChatAttachment }, GuestShareAccessError >; + /** An image on the host that an agent reply the guest sees shows, and its type. */ + readonly media: ( + session: string, + path: string, + ) => Effect.Effect<{ readonly path: string; readonly mimeType: string }, GuestShareAccessError>; } >()("t3/guest/GuestShareService") {} +/** A reply a guest's stream sent, with its image sources once a media request parsed them. */ +interface SentReply { + readonly text: string; + sources?: ReadonlyArray; +} + /** The thread record an event carries, for the share and the view. */ const threadOf = (event: OrchestrationV2DomainEvent): OrchestrationV2AppThread | null => { switch (event.type) { @@ -464,6 +482,40 @@ const make = Effect.gen(function* () { Effect.ignore, ); + // The image replies each share's guest stream has sent, in its opening snapshot or live, so an + // image loads while the guest's page can show its reply, inherited ones included. Keyed by + // share, so a new link on the thread starts empty. A share with an open stream keeps its + // record; past the limit, closed ones are dropped. + const sentImageReplies = new Map< + GuestShareId, + { openStreams: number; readonly replies: Map } + >(); + const sentRecord = (shareId: GuestShareId) => { + let record = sentImageReplies.get(shareId); + if (record === undefined) { + record = { openStreams: 0, replies: new Map() }; + sentImageReplies.set(shareId, record); + for (const [id, other] of sentImageReplies) { + if (sentImageReplies.size <= SHARED_THREAD_REPLIES_LIMIT) break; + if (other.openStreams === 0 && id !== shareId) sentImageReplies.delete(id); + } + } + return record; + }; + const rememberSent = (shareId: GuestShareId, items: Iterable) => { + for (const item of items) { + if (item.type !== "assistant_message") continue; + const key = `${item.threadId}\u0000${item.id}`; + // Only replies with image syntax can show an image. One edited to drop its images stops + // allowing them. + if (item.text.includes("![") || / => { if (item.kind !== "event") return []; if (endsShare(item.event)) return [ended]; + if (item.event.type === "turn-item.updated") rememberSent(share.id, [item.event.payload]); const updated = threadOf(item.event); const view = updated === null ? null : viewOf(updated); return view === null @@ -528,17 +581,15 @@ const make = Effect.gen(function* () { const untilExpiry = Effect.sleep( Math.max(0, Date.parse(share.expiresAt) - DateTime.toEpochMillis(yield* DateTime.now)), ); + const openingSnapshot = buildBoundedThreadStreamSnapshot({ + snapshotSequence: snapshot.snapshotSequence, + projection: snapshot.projection, + }); const opening: ReadonlyArray = [ { type: "view", view: initialView }, - { - type: "thread", - item: buildBoundedThreadStreamSnapshot({ - snapshotSequence: snapshot.snapshotSequence, - projection: snapshot.projection, - }), - }, + { type: "thread", item: openingSnapshot }, ]; - return Stream.fromIterable(opening).pipe( + const events = Stream.fromIterable(opening).pipe( Stream.concat( Stream.merge(live, Stream.fromEffect(Effect.as(untilExpiry, ended)), { haltStrategy: "either", @@ -557,6 +608,25 @@ const make = Effect.gen(function* () { ), ), ); + // The record is opened and counted when the stream runs, paired with its release, so a + // response whose body never runs (HEAD) touches nothing. + return Stream.unwrap( + Effect.acquireRelease( + Effect.sync(() => { + const record = sentRecord(share.id); + record.openStreams += 1; + rememberSent( + share.id, + openingSnapshot.projection.visibleTurnItems.map((row) => row.item), + ); + return record; + }), + (record) => + Effect.sync(() => { + record.openStreams -= 1; + }), + ).pipe(Effect.as(events)), + ); }); const unavailable = new GuestShareAccessError({ reason: "unavailable" }); @@ -630,6 +700,33 @@ const make = Effect.gen(function* () { return exists ? { path, attachment: found } : yield* unavailable; }); + const media = Effect.fn("GuestShareService.media")(function* (session: string, path: string) { + const { share } = yield* authorize(session, "session"); + const mimeType = guestHostImageType(path); + if (mimeType === null) return yield* unavailable; + const sent = sentImageReplies.get(share.id)?.replies.values() ?? []; + // Each sent reply is parsed once, on the first request that needs it. + if ( + !repliesShowImage( + path, + [...sent], + (reply) => (reply.sources ??= chatMarkdownImageSources(reply.text)), + ) + ) { + return yield* unavailable; + } + // The bytes must be the image the name says, as for a guest's own upload. + const header = yield* fileSystem.open(path, { flag: "r" }).pipe( + Effect.flatMap((file) => file.readAlloc(GUEST_UPLOAD_HEADER_BYTES)), + Effect.scoped, + Effect.mapError(() => unavailable), + ); + if (Option.isNone(header) || !matchesImageSignature(mimeType, header.value)) { + return yield* unavailable; + } + return { path, mimeType }; + }); + return GuestShareService.of({ create, redeem, @@ -640,6 +737,7 @@ const make = Effect.gen(function* () { item, commandOutput, attachment, + media, }); }); diff --git a/apps/server/src/guest/guestAttachments.test.ts b/apps/server/src/guest/guestAttachments.test.ts new file mode 100644 index 0000000000000..c65dd1364c2b0 --- /dev/null +++ b/apps/server/src/guest/guestAttachments.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { guestHostImageType, repliesShowImage } from "./guestAttachments.ts"; + +describe("guest host images", () => { + it.each([ + ["![a](/w/a.png)", "/w/a.png", "image/png"], + ["![a]()", "/w/my shot.jpg", "image/jpeg"], + ["![a](/w/shot(1).png)", "/w/shot(1).png", "image/png"], + ["![a](/w/shot\\_1.png) ![b](/w/a&b.png)", "/w/shot_1.png", "image/png"], + ["![a](/w/shot\\_1.png) ![b](/w/a&b.png)", "/w/a&b.png", "image/png"], + ["![a](file:///w/caf%c3%a9.png)", "/w/café.png", "image/png"], + ["![a](file:///w/%73hot.png) ![b](/w/shot2.png)", "/w/shot.png", "image/png"], + ["![a](file:///w/%73hot.png) ![b](/w/shot2.png)", "/w/shot2.png", "image/png"], + ["![a][r]\n\n[r]: /w/ref.png", "/w/ref.png", "image/png"], + ["![a](file:///w/url%20form.png)", "/w/url form.png", "image/png"], + ["![a](C:\\w\\win.png)", "C:\\w\\win.png", "image/png"], + ["![a][r]\n\n[r]: C:\\w\\ref.png", "C:/w/ref.png", "image/png"], + // Shown as a link, in code, or not at all: the guest sees no image, so none loads. + ["[a](/w/link.png)", "/w/link.png", null], + ["```\n![a](/w/code.png)\n```", "/w/code.png", null], + ["`![a](/w/span.png)`", "/w/span.png", null], + ["", "/w/comment.png", null], + ["![a](/w/a.png)", "/a.png", null], + ["![a](//host/s/x.png)", "//host/s/x.png", null], + ["![a](\\\\host\\s\\x.png)", "\\\\host\\s\\x.png", null], + ["![a](/w/a.svg)", "/w/a.svg", null], + ["![a](/w/a.constructor)", "/w/a.constructor", null], + ])("%j serves %j as %j", (text, path, type) => { + const served = guestHostImageType(path) !== null && repliesShowImage(path, [{ text }]); + expect(served ? guestHostImageType(path) : null).toBe(type); + }); +}); diff --git a/apps/server/src/guest/guestAttachments.ts b/apps/server/src/guest/guestAttachments.ts index ab7bd1a78b8e5..5118899620d64 100644 --- a/apps/server/src/guest/guestAttachments.ts +++ b/apps/server/src/guest/guestAttachments.ts @@ -3,6 +3,9 @@ import { GUEST_MESSAGE_MAX_ATTACHMENTS, isProviderSendTurnSupportedImageMimeType, } from "@t3tools/contracts"; +import { markdownDestinationHostPath } from "@t3tools/shared/markdownLinks"; +import { chatMarkdownImageSources } from "@t3tools/shared/markdownPipeline"; +import { isAbsolutePath, isNetworkPath } from "@t3tools/shared/path"; /** A file from a guest's message request, saved by the multipart parser to a temporary path. */ export interface GuestUpload { @@ -46,6 +49,44 @@ const IMAGE_SIGNATURES: Readonly boolean> startsWith(header, ascii("RIFF")) && startsWith(header, ascii("WEBP"), 8), }; +export const matchesImageSignature = (mimeType: string, header: Uint8Array) => + IMAGE_SIGNATURES[mimeType]?.(header) === true; + +const IMAGE_TYPE_BY_EXTENSION: ReadonlyMap = new Map([ + ["png", "image/png"], + ["jpg", "image/jpeg"], + ["jpeg", "image/jpeg"], + ["gif", "image/gif"], + ["webp", "image/webp"], +]); + +/** + * The type of a host image a guest may ask for by path, or null. A network path never qualifies: + * it would make the host reach out to another machine. + */ +export function guestHostImageType(path: string): string | null { + if (!isAbsolutePath(path) || isNetworkPath(path)) return null; + return IMAGE_TYPE_BY_EXTENSION.get(path.split(".").at(-1)!.toLowerCase()) ?? null; +} + +/** + * Whether an agent reply shows the image at `path`, so the guest already sees it. `imageSources` + * lets the caller reuse a reply's parse across requests. + */ +export function repliesShowImage( + path: string, + replies: ReadonlyArray, + imageSources: (reply: Reply) => ReadonlyArray = (reply) => + chatMarkdownImageSources(reply.text), +) { + // Parsing is the slow part; a reply with no image syntax shows no image. + return replies.some( + (reply) => + (reply.text.includes("![") || / markdownDestinationHostPath(source) === path), + ); +} + /** * Control characters and separators would break the name in headers and on disk, and direction * overrides would let `invoice\u202Efdp.exe` show as a PDF. @@ -76,11 +117,9 @@ export function classifyGuestUploads( upload.contentType.split(";", 1)[0]!.trim().toLowerCase() || "application/octet-stream"; if (upload.sizeBytes === 0) return { ok: false, reason: "unsupported_file" }; if (mimeType.startsWith("image/")) { - const signatureMatches = IMAGE_SIGNATURES[mimeType]; if ( !isProviderSendTurnSupportedImageMimeType(mimeType) || - signatureMatches === undefined || - !signatureMatches(upload.header) + !matchesImageSignature(mimeType, upload.header) ) { return { ok: false, reason: "unsupported_file" }; } diff --git a/apps/server/src/guest/http.ts b/apps/server/src/guest/http.ts index 7c8356119deb5..bf368ac884d92 100644 --- a/apps/server/src/guest/http.ts +++ b/apps/server/src/guest/http.ts @@ -2,6 +2,7 @@ import { GUEST_ATTACHMENT_ROUTE, GUEST_COMMAND_OUTPUT_ROUTE, GUEST_ITEM_ROUTE, + GUEST_MEDIA_ROUTE, GUEST_RENDER_ROUTE, GUEST_ROUTE_PREFIX, GUEST_SHARE_PARAM, @@ -34,6 +35,12 @@ import { GuestShareAccessError, GuestShareService } from "./GuestShareService.ts // Cloudflare closes idle streams after about 100 seconds. const HEARTBEAT_INTERVAL = Duration.seconds(20); const NO_STORE = { "cache-control": "no-store" }; +// A file never runs as a page on this origin, whatever its bytes. +const FILE_HEADERS = { + ...NO_STORE, + "x-content-type-options": "nosniff", + "content-security-policy": "default-src 'none'; sandbox", +}; const decodeRedeem = Schema.decodeUnknownEffect(GuestRedeemRequest); const decodeSubmit = Schema.decodeUnknownEffect(GuestSubmitRequest); @@ -315,9 +322,7 @@ const attachmentRoute = HttpRouter.add( Effect.flatMap(({ path, attachment }) => HttpServerResponse.file(path, { headers: { - ...NO_STORE, - "x-content-type-options": "nosniff", - "content-security-policy": "default-src 'none'; sandbox", + ...FILE_HEADERS, ...(attachment.type === "image" && isProviderSendTurnSupportedImageMimeType(attachment.mimeType) ? { "content-type": attachment.mimeType } @@ -334,6 +339,25 @@ const attachmentRoute = HttpRouter.add( }), ); +// Only an image an agent reply shows loads; anything else on the host is refused. +const mediaRoute = HttpRouter.add( + "GET", + GUEST_MEDIA_ROUTE, + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const path = new URLSearchParams(request.url.split("?", 2)[1] ?? "").get("path"); + if (!path) return yield* badRequest; + const guestShares = yield* GuestShareService; + return yield* guestShares.media(yield* sessionToken, path).pipe( + Effect.flatMap(({ path, mimeType }) => + HttpServerResponse.file(path, { headers: { ...FILE_HEADERS, "content-type": mimeType } }), + ), + Effect.catchTags({ GuestShareAccessError: errorResponse }), + Effect.orElseSucceed(() => HttpServerResponse.empty({ status: 500, headers: NO_STORE })), + ); + }), +); + export const layer = Layer.mergeAll( redeemRoute, sessionRoute, @@ -343,4 +367,5 @@ export const layer = Layer.mergeAll( itemRoute, commandOutputRoute, attachmentRoute, + mediaRoute, ); diff --git a/apps/web/src/components/ChatMarkdown.browser.test.tsx b/apps/web/src/components/ChatMarkdown.browser.test.tsx index d67803a6399ce..345bde84976c3 100644 --- a/apps/web/src/components/ChatMarkdown.browser.test.tsx +++ b/apps/web/src/components/ChatMarkdown.browser.test.tsx @@ -10,6 +10,7 @@ import "../index.css"; import { filesystemEnvironment } from "../state/filesystem"; import { useRightPanelStore } from "../rightPanelStore"; import { setMarkdownTaskChecked } from "./files/filePreviewMode"; +import { HostImageUrlContext } from "./chat/hostImageUrl"; const { workspaceSearch, filesystemMetadata } = vi.hoisted(() => ({ workspaceSearch: vi.fn(), @@ -122,6 +123,22 @@ it("shows unavailable audio when a workspace embed has no thread", async () => { expect(screen.container.querySelector("audio")).toBeNull(); }); +it("loads a host image through the page's own route when there is no environment", async () => { + setup(); + const pixel = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="; + const hostImageUrl = vi.fn((_path: string) => pixel); + await using screen = await render( + + + , + ); + await expect.element(screen.getByAltText("Shot")).toHaveAttribute("src", pixel); + expect(new Set(hostImageUrl.mock.calls.map(([path]) => path))).toEqual( + new Set(["/work/my shot.png"]), + ); +}); + describe("ChatMarkdown favicon privacy", () => { it("suppresses private link images while preserving public links across updates", async () => { setup(); diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 2ae3e8d9f971a..32f15bcf542d7 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -65,6 +65,7 @@ import { import { mediaUrlReference } from "@t3tools/client-runtime/media-reference"; import { mediaKindFromPath, mediaMimeTypeFromExtension } from "@t3tools/shared/mediaPreview"; import { InlineAudioPreview } from "./files/InlineAudioPreview"; +import { HostImageUrlContext } from "./chat/hostImageUrl"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; @@ -1483,6 +1484,24 @@ const CHAT_MARKDOWN_COMPONENTS = { /> ); } + const hostImageUrl = use(HostImageUrlContext); + if (imageSource._tag === "WorkspaceFile" && kind === "image" && hostImageUrl !== null) { + const hostSrc = hostImageUrl(imageSource.path); + return ( + + ); + } if (imageSource._tag === "WorkspaceFile" && threadRef) { return ( string) | null>(null); diff --git a/apps/web/src/components/guest/GuestPage.tsx b/apps/web/src/components/guest/GuestPage.tsx index 3ec7b2d542335..6d72741691f92 100644 --- a/apps/web/src/components/guest/GuestPage.tsx +++ b/apps/web/src/components/guest/GuestPage.tsx @@ -45,6 +45,7 @@ import type { ChatComposerHandle } from "../chat/ChatComposer"; import { ComposerControl, ComposerControlIcon } from "../chat/ComposerControl"; import type { ExpandedImagePreview } from "../chat/ExpandedImagePreview"; import { GuestMessageAttachments } from "../chat/GuestMessageAttachments"; +import { HostImageUrlContext } from "../chat/hostImageUrl"; import { PierreEntryIcon } from "../chat/PierreEntryIcon"; import { GuestMessageName, guestBubbleClassName } from "../chat/guestMessageStyle"; import { MessagesTimeline } from "../chat/MessagesTimeline"; @@ -60,6 +61,7 @@ import { applyGuestStreamEvent, formatGuestExpiry, guestAttachmentUrl, + guestMediaUrl, createGuestAttachmentUrls, guestFilesProblem, initialGuestStreamState, @@ -387,6 +389,7 @@ function GuestThread({ const openFile = useStableCallback((file: { readonly id: string }) => openInNewTab(guestAttachmentUrl(shareId, file.id)), ); + const hostImageUrl = useStableCallback((path: string) => guestMediaUrl(shareId, path)); const activityRun = deriveThreadActivityRun(projection); const runtime = deriveThreadStructureRuntime(projection); const runlessWorkStartedAt = deriveRunlessWorkStartedAt(projection); @@ -448,50 +451,52 @@ function GuestThread({
- - {unplaced.map((message) => ( -
  • - -
  • - ))} - - ) - } - /> + + + {unplaced.map((message) => ( +
  • + +
  • + ))} + + ) + } + /> +
    {follow.showScrollToBottom ? ( `${GUEST_ATTACHMENT_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&id=${encodeURIComponent(attachmentId)}`; +/** Where the guest page loads an image file on the host that an agent message embeds. */ +export const guestMediaUrl = (shareId: string, path: string) => + `${GUEST_MEDIA_ROUTE}?${GUEST_SHARE_PARAM}=${encodeURIComponent(shareId)}&path=${encodeURIComponent(path)}`; + /** * Why these files cannot go with one message, or null. The server checks the same limits; this * only says so before the upload. diff --git a/docs/user/guest-links.md b/docs/user/guest-links.md index b97e400c65146..a8631a6c93b28 100644 --- a/docs/user/guest-links.md +++ b/docs/user/guest-links.md @@ -26,9 +26,9 @@ unlinking the machine from T3 Connect also ends it. You can send or dismiss gues ## What the guest sees The guest sees the thread's recent history and everything after, in the same timeline you see, as -it streams: messages and their files, the agent's replies and reasoning, plans, questions, and its -work, with command output when they expand a command. Their composer shows the thread's model, but -they cannot change it. They do not see diffs, and they cannot act on the thread: no forking, +it streams: messages and their files, the agent's replies with the images they show from your +machine, its reasoning, plans, questions, and its work, with command output when they expand a +command. Their composer shows the thread's model, but they cannot change it. They do not see diffs, and they cannot act on the thread: no forking, reverting, opening files on your machine, or answering the agent for you. Anything the agent read or printed can show, such as a value from a file. Share only threads you diff --git a/packages/client-runtime/src/markdownImages.ts b/packages/client-runtime/src/markdownImages.ts index 924220ec27ce4..94cba5c9f842b 100644 --- a/packages/client-runtime/src/markdownImages.ts +++ b/packages/client-runtime/src/markdownImages.ts @@ -1,16 +1,14 @@ import { isNetworkPath, isWindowsAbsolutePath, - stripSlashPrefixedWindowsDrive, workspaceRelativeFilePath, } from "@t3tools/shared/path"; import { + markdownDestinationHostPath, normalizeMarkdownLinkDestination, - safeDecodeURIComponent, splitMarkdownLinkSearchAndHash, } from "@t3tools/shared/markdownLinks"; -import { parseFileUrlHref } from "@t3tools/shared/fileLinks"; const DIRECT_IMAGE_SOURCE_PATTERN = /^(?:https?:|data:|blob:|\/\/)/i; const URI_SCHEME_PATTERN = /^[A-Za-z][A-Za-z0-9+.-]*:/; @@ -59,19 +57,9 @@ export function classifyMarkdownImageSource( return { _tag: "Direct", uri: source }; } - if (/^file:/i.test(source)) { - const target = parseFileUrlHref(source); - return target === null - ? { _tag: "Blocked" } - : hostFileSource( - stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)), - workspaceRoot, - ); - } - - const path = stripSlashPrefixedWindowsDrive( - safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), - ); + const path = markdownDestinationHostPath(source); + if (path === null) return { _tag: "Blocked" }; + if (/^file:/i.test(source)) return hostFileSource(path, workspaceRoot); if (path.length === 0) return { _tag: "Blocked" }; if (path.startsWith("/") || isWindowsAbsolutePath(path)) { return hostFileSource(path, workspaceRoot); diff --git a/packages/contracts/src/guestShare.ts b/packages/contracts/src/guestShare.ts index fa4b3a256f013..fd991fe1e4c3b 100644 --- a/packages/contracts/src/guestShare.ts +++ b/packages/contracts/src/guestShare.ts @@ -136,3 +136,8 @@ export const GUEST_COMMAND_OUTPUT_ROUTE = `${GUEST_ROUTE_PREFIX}/command-output` * Images load inline; other files download. */ export const GUEST_ATTACHMENT_ROUTE = `${GUEST_ROUTE_PREFIX}/attachment`; +/** + * An image file on the host that an agent message in the shared thread embeds: + * `?share=&path=`. + */ +export const GUEST_MEDIA_ROUTE = `${GUEST_ROUTE_PREFIX}/media`; diff --git a/packages/shared/src/markdownLinks.ts b/packages/shared/src/markdownLinks.ts index 0445fa42d8a0a..177e6fcbf5008 100644 --- a/packages/shared/src/markdownLinks.ts +++ b/packages/shared/src/markdownLinks.ts @@ -227,6 +227,22 @@ export function splitMarkdownLinkSearchAndHash(value: string): { }; } +/** + * The decoded path a markdown destination names on the host, before any workspace resolution, or + * null for a `file:` URL that does not parse. Clients and the guest media route both read it here. + */ +export function markdownDestinationHostPath(source: string): string | null { + if (/^file:/i.test(source)) { + const target = parseFileUrlHref(source); + return target === null + ? null + : stripSlashPrefixedWindowsDrive(safeDecodeURIComponent(target.path)); + } + return stripSlashPrefixedWindowsDrive( + safeDecodeURIComponent(splitMarkdownLinkSearchAndHash(source).path), + ); +} + export function isMarkdownFileLinkLabel(label: string, href: string): boolean { const destination = parseMarkdownFileLink(href); if (!destination) return false; diff --git a/packages/shared/src/markdownPipeline.ts b/packages/shared/src/markdownPipeline.ts index 275b93b562f6b..fe3d73ddac65c 100644 --- a/packages/shared/src/markdownPipeline.ts +++ b/packages/shared/src/markdownPipeline.ts @@ -1,6 +1,8 @@ import type { Paragraph, PhrasingContent, Root, RootContent } from "mdast"; import { remarkKeepWindowsPaths } from "./markdownWindowsPaths.ts"; -import type { PluggableList } from "unified"; +import { type PluggableList, unified } from "unified"; +import remarkParse from "remark-parse"; +import remarkRehype from "remark-rehype"; import rehypeRaw from "rehype-raw"; import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; import remarkBreaks from "remark-breaks"; @@ -11,7 +13,7 @@ import { CODEX_ARTIFACT_TEMPLATE_HAST_PROPERTIES, remarkCodexDirectives, } from "./codexMarkdownDirectives.ts"; -import { isWindowsDrivePathHref } from "./markdownLinks.ts"; +import { isWindowsDrivePathHref, normalizeMarkdownLinkDestination } from "./markdownLinks.ts"; import { THREAD_LINK_PROTOCOL } from "./threadLinks.ts"; type MarkdownImageHastNode = { @@ -304,3 +306,38 @@ function remarkNormalizeLinksAndTagInlineCode() { export function shouldPreserveAssistantLineBreaks(text: string): boolean { return /^★ Insight(?:\s|─)/mu.test(text); } + +const imageSourceProcessor = unified() + .use(remarkParse) + .use(CHAT_MARKDOWN_REMARK_PLUGINS) + .use(remarkRehype, { allowDangerousHtml: true }) + .use(CHAT_MARKDOWN_REHYPE_PLUGINS); + +interface ImageSourceNode { + readonly type: string; + readonly tagName?: string; + readonly properties?: { readonly src?: unknown; readonly dataLocalSrc?: unknown }; + readonly children?: ReadonlyArray; +} + +/** + * The sources of the images chat markdown shows, read as ChatMarkdown's `img` renderer reads + * them. Code, comments, and plain links show no image, so they add none. + */ +export function chatMarkdownImageSources(markdown: string): Array { + const sources: Array = []; + const visit = (node: ImageSourceNode) => { + if (node.type === "element" && node.tagName === "img") { + const { src, dataLocalSrc } = node.properties ?? {}; + // The sanitizer drops a Windows drive path from `src`; the renderer reads it from here. + if (typeof dataLocalSrc === "string") { + sources.push(normalizeMarkdownLinkDestination(dataLocalSrc).replaceAll("\\", "/")); + } else if (typeof src === "string") { + sources.push(normalizeMarkdownLinkDestination(src)); + } + } + node.children?.forEach(visit); + }; + visit(imageSourceProcessor.runSync(imageSourceProcessor.parse(markdown)) as ImageSourceNode); + return sources; +} From a9418a1206f9f092d6d5769f6a62fb8da2ecc6ec Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 23:53:18 -0700 Subject: [PATCH 192/317] ci(release): releases no longer reinstall Vite+ in every job (#26305) Co-authored-by: Claude Opus 5.5 --- .github/actions/setup-ci-deps/action.yml | 61 ++++++++++++--- .github/workflows/release-desktop.yml | 13 +--- .github/workflows/release.yml | 98 +++++++----------------- 3 files changed, 83 insertions(+), 89 deletions(-) diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e0ef427bd1d36..afaef17e1c310 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -5,6 +5,14 @@ inputs: description: Read-only credential for the private maintainer task cache required: false default: "" + install: + description: Set to "false" to set up Vite+ only, with no pnpm store or install + required: false + default: "true" + install-args: + description: Arguments for `vp install`, such as filters. Empty installs the whole workspace. + required: false + default: "" runs: using: composite steps: @@ -19,10 +27,11 @@ runs: echo "node=$(jq -r '.engines.node' package.json)" >> "$GITHUB_OUTPUT" echo "pnpm=$(jq -r '.packageManager' package.json)" >> "$GITHUB_OUTPUT" + # Pinned because release-desktop.yml signs builds with this action in the job. - name: Cache Vite+ id: vp_cache if: steps.vp.outputs.version != '' - uses: actions/cache@v6 + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.vite-plus key: vite-plus-home-v2-${{ runner.os }}-${{ runner.arch }}-${{ steps.vp.outputs.version }}-node${{ steps.vp.outputs.node }}-${{ steps.vp.outputs.pnpm }} @@ -40,36 +49,66 @@ runs: - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' - uses: voidzero-dev/setup-vp@v1 + uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 with: node-version-file: package.json cache: false run-install: false - - name: Resolve pnpm store path + # A shared key lets whichever install saves first, often a small filtered + # one, become every job's store. Each set of install args gets its own. + - name: Resolve pnpm store id: pnpm_store + if: inputs.install == 'true' shell: bash + env: + INSTALL_ARGS: ${{ inputs.install-args }} run: | - store_path="$(vp pm cache dir)" - echo "path=$store_path" >> "$GITHUB_OUTPUT" + echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + if [[ -z "$INSTALL_ARGS" ]]; then + echo "namespace=full" >> "$GITHUB_OUTPUT" + else + echo "namespace=filtered-$(printf '%s' "$INSTALL_ARGS" | cksum | cut -d' ' -f1)" >> "$GITHUB_OUTPUT" + fi - # setup-vp's shared cache key ignores install filters. Keep full installs in a - # separate namespace, including fallback restores. + # Filtered installs prefer the current full store, which holds every package, + # over an older store of their own. # The pnpm store stays a cache tarball on the runner's own disk. A store on a # Blacksmith sticky disk mounts in under a second, but pnpm then copies every # package across filesystems instead of hard-linking it: install took 28-49s # against 9s plus an 11s restore. - - name: Cache full pnpm store - uses: actions/cache@v6 + - name: Restore pnpm store + id: pnpm_cache + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ${{ steps.pnpm_store.outputs.path }} - key: pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + key: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} restore-keys: | + pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- - name: Install dependencies + if: inputs.install == 'true' shell: bash - run: vp install + env: + INSTALL_ARGS: ${{ inputs.install-args }} + # Unquoted so each filter is its own argument. + run: vp install $INSTALL_ARGS + + # A filtered install that started from the current full store adds nothing + # worth a second copy of it. + - name: Save pnpm store + if: >- + inputs.install == 'true' && + steps.pnpm_cache.outputs.cache-hit != 'true' && + (steps.pnpm_store.outputs.namespace == 'full' || + steps.pnpm_cache.outputs.cache-matched-key != format('pnpm-full-v1-{0}-{1}-{2}', runner.os, runner.arch, hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json'))) + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.path }} + key: ${{ steps.pnpm_cache.outputs.cache-primary-key }} - name: Enable shared task cache if: >- diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index bff1b125be44b..ca418467f6b9c 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -147,13 +147,6 @@ jobs: git sparse-checkout set --no-cone '/*' '!/.repos/' git -c checkout.workers=0 checkout --detach FETCH_HEAD - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -163,10 +156,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install desktop dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=@t3tools/desktop... --filter=t3... --filter=@t3tools/scripts... - name: Cache resource monitor id: resource_monitor_cache diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33076d208f3a4..e8f300242f054 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -420,14 +420,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3code-relay... + install-args: --filter=t3code-relay... # The deployed stack's outputs, read from Alchemy's state store without # planning or applying. Select the backend explicitly to avoid loading @@ -522,13 +518,6 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 - with: - node-version-file: package.json - cache: true - run-install: false - # pnpm checks the lockfile and policy before reusing this result. A missing # artifact leaves the cache empty, so installation runs the checks again. - name: Download dependency verification @@ -538,10 +527,12 @@ jobs: name: release-dependency-verification path: ${{ runner.temp }}/pnpm-metadata - - name: Install bundle dependencies + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps + with: + install-args: --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... env: pnpm_config_cache_dir: ${{ runner.temp }}/pnpm-metadata - run: vp install --filter=t3... --filter=@t3tools/web... --filter=@t3tools/desktop... --filter=@t3tools/scripts... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -781,14 +772,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Download all desktop artifacts uses: actions/download-artifact@v8 @@ -977,15 +964,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=t3... - - --filter=@t3tools/scripts... + install-args: --filter=t3... --filter=@t3tools/scripts... - name: Build the npm launcher run: node scripts/build-npm-launcher.ts --version "${{ needs.preflight.outputs.version }}" --output-dir npm-packages @@ -1041,15 +1023,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/web... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/web... - name: Download relay client tracing config uses: actions/download-artifact@v8 @@ -1145,11 +1122,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Alias channel shell: bash @@ -1216,14 +1191,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/marketing... + install-args: --filter=@t3tools/marketing... - id: deploy name: Build staged deployment @@ -1287,11 +1258,9 @@ jobs: sparse-checkout-cone-mode: false - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: false - run-install: false + install: false - name: Promote marketing site shell: bash @@ -1346,15 +1315,10 @@ jobs: echo "name=${APP_SLUG}[bot]" >> "$GITHUB_OUTPUT" echo "email=${user_id}+${APP_SLUG}[bot]@users.noreply.github.com" >> "$GITHUB_OUTPUT" - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... - - --filter=@t3tools/oxlint-plugin-t3code... + install-args: --filter=@t3tools/scripts... --filter=@t3tools/oxlint-plugin-t3code... - id: update_versions name: Update version strings @@ -1411,14 +1375,10 @@ jobs: !/.repos/ sparse-checkout-cone-mode: false - - name: Setup Vite+ - uses: voidzero-dev/setup-vp@v1 + - name: Setup dependencies + uses: ./.github/actions/setup-ci-deps with: - node-version-file: package.json - cache: true - run-install: | - args: - - --filter=@t3tools/scripts... + install-args: --filter=@t3tools/scripts... - name: Read published release notes if: needs.preflight.outputs.is_prerelease == 'true' From f6d3700ac3c506250260fcb143c5e795cbbaeb9f Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Tue, 27 Oct 2026 23:55:40 -0700 Subject: [PATCH 193/317] ci: server test shards survive a throttled task cache (#26332) Co-authored-by: Claude Opus 5.5 --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca294dd4a701a..e4c45728c2342 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -354,7 +354,9 @@ jobs: id-token: write name: Test Server ${{ matrix.shard }} runs-on: blacksmith-4vcpu-ubuntu-2404 - timeout-minutes: 10 + # Shards take 2-5 minutes. When the shared task cache throttles reads, each + # failed fetch stalls vp for 15-90s, which pushed shards past 10 minutes. + timeout-minutes: 20 strategy: fail-fast: false matrix: From 4e4d4ba3959cae17db7bba1915e6a25885844484 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 00:05:49 -0700 Subject: [PATCH 194/317] feat(web): boring sidebar groups threads into project folders (#21039) Co-authored-by: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 2 +- apps/web/package.json | 1 - apps/web/src/components/AppSidebarLayout.tsx | 10 +- apps/web/src/components/LegacySidebar.tsx | 3743 ----------------- apps/web/src/components/Sidebar.logic.test.ts | 98 + apps/web/src/components/Sidebar.logic.ts | 125 +- apps/web/src/components/Sidebar.tsx | 144 +- .../src/components/ThreadStatusIndicators.tsx | 4 +- .../components/settings/LabsSettingsPanel.tsx | 23 + .../components/settings/SettingsPanels.tsx | 19 +- .../settings/settingsSearch.test.ts | 2 +- .../src/components/settings/settingsSearch.ts | 12 +- .../sidebar/SidebarProjectFolderHeader.tsx | 115 + .../components/sidebar/SidebarThreadRow.tsx | 84 +- apps/web/src/components/ui/sidebar.tsx | 2 +- apps/web/src/connection/desktopLocal.ts | 4 - apps/web/src/hooks/useMediaQuery.ts | 4 - apps/web/src/hooks/useSettings.ts | 10 +- apps/web/src/shortcuts/chatRouteCommands.ts | 6 +- .../src/state/use-orchestration-command.ts | 3 +- docs/user/thread-sidebar.md | 9 + .../src/state/projectGrouping.ts | 2 +- packages/contracts/src/settings.ts | 18 +- pnpm-lock.yaml | 8 - 24 files changed, 549 insertions(+), 3899 deletions(-) delete mode 100644 apps/web/src/components/LegacySidebar.tsx create mode 100644 apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index 9f76003c19c8d..ac4c6e61378e3 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -63,7 +63,7 @@ const clientSettings: ClientSettings = { }, sidebarProjectSortOrder: "manual", sidebarThreadSortOrder: "created_at", - sidebarThreadPreviewCount: 6, + boringSidebarEnabled: false, legacySidebarEnabled: false, sidebarWorkingShelfEnabled: false, loadBalancingEnabled: false, diff --git a/apps/web/package.json b/apps/web/package.json index de75da24e380b..729d44f8ea6cd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -27,7 +27,6 @@ "@dnd-kit/utilities": "^3.2.2", "@effect/atom-react": "catalog:", "@fontsource-variable/inter": "^5.3.0", - "@formkit/auto-animate": "^0.9.0", "@legendapp/list": "catalog:", "@noble/hashes": "catalog:", "@pierre/diffs": "catalog:", diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index 20abcf1e57ade..6c8837e63f40a 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -31,14 +31,13 @@ import { selectActiveRightPanel, useRightPanelStore } from "../rightPanelStore"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { cn, isMacPlatform } from "../lib/utils"; import { primaryServerKeybindingsAtom } from "../state/server"; -import { useEnvironmentIdentificationMode, useLegacySidebarEnabled } from "../hooks/useSettings"; +import { useEnvironmentIdentificationMode } from "../hooks/useSettings"; import { PanelAnimationSuppressionProvider, usePanelAnimationSettings, usePanelNavigationSuppression, } from "../panelAnimations"; import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; -import LegacyThreadSidebar from "./LegacySidebar"; import ThreadSidebar from "./Sidebar"; import { SidebarBrandWidthProbe } from "./sidebar/SidebarChrome"; import { isAppModalPage } from "./sidebar/mainAppLocation"; @@ -403,7 +402,6 @@ function SidebarControl() { export function AppSidebarLayout({ children }: { children: ReactNode }) { const sidebarArea = workspaceAreaProps("sidebar", 0, "Threads"); - const legacySidebarEnabled = useLegacySidebarEnabled(); const [sidebarRouteReveal] = useState(() => ({ observedKey: null, pendingKey: null, @@ -470,11 +468,7 @@ export function AppSidebarLayout({ children }: { children: ReactNode }) { }} style={windowControlsInsetStyle} > - {legacySidebarEnabled ? ( - - ) : ( - - )} + {children} diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx deleted file mode 100644 index 27ea62b61511a..0000000000000 --- a/apps/web/src/components/LegacySidebar.tsx +++ /dev/null @@ -1,3743 +0,0 @@ -import { attachSidebarTree, SIDEBAR_TREE_PROPS } from "../lib/sidebarTree"; -import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; -import { resolveThreadCurrentPullRequestLink } from "@t3tools/client-runtime/thread-pull-requests"; -import { Spinner } from "~/components/ui/spinner"; -import { - ArchiveIcon, - ArrowUpDownIcon, - ChevronRightIcon, - FolderPlusIcon, - Globe2Icon, - SearchIcon, - SquarePenIcon, - TriangleAlertIcon, -} from "lucide-react"; -import { - ChangeRequestStatusIcon, - prStatusIndicator, - PrStatusTooltipContent, - ThreadTerminalStatusButton, - ThreadStatusLabel, - ThreadWorktreeIndicator, - useLinkedThreadPullRequest, -} from "./ThreadStatusIndicators"; -import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { ProjectFavicon } from "./ProjectFavicon"; -import { useAtomValue } from "@effect/atom-react"; -import { autoAnimate } from "@formkit/auto-animate"; -import React, { - createContext, - useEffect, - useEffectEvent, - memo, - useContext, - useRef, - useState, -} from "react"; -import { cn } from "~/lib/utils"; -import { useShallow } from "zustand/react/shallow"; -import { - DndContext, - type DragCancelEvent, - type CollisionDetection, - PointerSensor, - type DragStartEvent, - closestCorners, - pointerWithin, - useSensor, - useSensors, - type DragEndEvent, -} from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; -import { restrictToFirstScrollableAncestor, restrictToVerticalAxis } from "@dnd-kit/modifiers"; -import { CSS } from "@dnd-kit/utilities"; -import { - AuthOrchestrationOperateScope, - AuthPreviewOperateScope, - type ContextMenuItem, - type EnvironmentId, - ProjectId, - type ScopedThreadRef, - type ResolvedKeybindingsConfig, - type SidebarProjectGroupingMode, - resolveEnvironmentMachineKind, - ThreadId, -} from "@t3tools/contracts"; -import { - parseScopedThreadKey, - scopedProjectKey, - scopedThreadKey, - scopeProjectRef, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; -import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; -import { - isAtomCommandInterrupted, - settlePromise, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; -import { threadRuntimeCanArchive } from "@t3tools/client-runtime/state/models"; -import { useNavigate, useRouter } from "@tanstack/react-router"; -import { - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - MIN_SIDEBAR_THREAD_PREVIEW_COUNT, - type SidebarProjectSortOrder, - type SidebarThreadPreviewCount, - type SidebarThreadSortOrder, -} from "@t3tools/contracts/settings"; -import { isDesktopLocalConnectionTarget, isWslConnectionTarget } from "../connection/desktopLocal"; -import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; -import { isElectron } from "../env"; -import { useStableCallback } from "../hooks/useStableCallback"; -import { useTerminalFocus } from "../hooks/useTerminalFocus"; -import { useOpenPrLink } from "../lib/openPullRequestLink"; -import { releaseProjectDraftUploads } from "../lib/composerDraftUploads"; -import { isTerminalFocused } from "../lib/terminalFocus"; -import { isMacPlatform } from "../lib/utils"; -import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; -import { - readThreadShell, - useProjects, - useDelegatedUnderParent, - useThreadShells, - useThreadShellsForProjectRefs, - waitForProjectRemoval, -} from "../state/entities"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadRunningTerminalIds } from "../state/terminalSessions"; -import { useThreadDiscoveredPorts } from "../portDiscoveryState"; -import { openDiscoveredPort } from "./preview/openDiscoveredPort"; -import { useAtomCommand } from "../state/use-atom-command"; -import { - ORCHESTRATION_OPERATE_DENIED_MESSAGE, - useOrchestrationCommand, -} from "../state/use-orchestration-command"; -import { previewEnvironment } from "../state/preview"; -import { readEnvironmentScope, useEnvironmentScope } from "../state/session"; -import { - legacyProjectCwdPreferenceKey, - resolveProjectExpanded, - useUiStateStore, -} from "../uiStateStore"; -import { - resolveShortcutCommand, - shortcutLabelForCommand, - shouldShowThreadJumpHintsForModifiers, - threadJumpCommandForIndex, - threadJumpIndexFromCommand, - threadTraversalDirectionFromCommand, -} from "../keybindings"; -import { isModelPickerOpen } from "../modelPickerVisibility"; -import { useShortcutModifierState } from "../shortcutModifierState"; -import { ensureLocalApi, readLocalApi } from "../localApi"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useNewThreadHandler } from "../hooks/useHandleNewThread"; -import { useDesktopUpdateState } from "../state/desktopUpdate"; - -import { useThreadActions } from "../hooks/useThreadActions"; -import { projectEnvironment } from "../state/projects"; -import { threadEnvironment, useEnvironmentThread } from "../state/threads"; -import { readUnloadedThreadCount } from "../state/olderSettledShelf"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; -import { buildThreadRouteParams, resolveActiveThreadRouteRef } from "../threadRoutes"; -import { stackedThreadToast, toastManager } from "./ui/toast"; -import { copyThreadReference } from "../lib/copyThreadReference"; -import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; -import { RelativeTime } from "./RelativeTime"; -import { Kbd } from "./ui/kbd"; -import { - getArm64IntelBuildWarningDescription, - getDesktopUpdateActionError, - getDesktopUpdateInstallConfirmationMessage, - isDesktopUpdateButtonDisabled, - resolveDesktopUpdateButtonAction, - shouldShowArm64IntelBuildWarning, - shouldToastDesktopUpdateActionResult, -} from "./desktopUpdate.logic"; -import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert"; -import { Button } from "./ui/button"; -import { - Dialog, - DialogDescription, - DialogFooter, - DialogHeader, - DialogPanel, - DialogPopup, - DialogTitle, -} from "./ui/dialog"; -import { Input } from "./ui/input"; -import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; -import { - NumberField, - NumberFieldDecrement, - NumberFieldGroup, - NumberFieldIncrement, - NumberFieldInput, -} from "./ui/number-field"; -import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; -import { - SidebarContent, - SidebarGroup, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, - useSidebar, -} from "./ui/sidebar"; -import { - getThreadKeysToDeselectAfterDelete, - useThreadSelectionStore, -} from "../threadSelectionStore"; -import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; -import { - archiveSelectedThreadEntries, - buildMultiSelectThreadContextMenuItems, - deleteSelectedThreadEntries, - filterSidebarV2VisibleThreads, - getSidebarThreadIdsToPrewarm, - resolveAdjacentThreadId, - isContextMenuPointerDown, - isSidebarNestedLinkClick, - isTrailingDoubleClick, - resolveProjectStatusIndicator, - resolveThreadLastVisitedAt, - resolveThreadStatusPill, - orderItemsByPreferredIds, - shouldClearThreadSelectionOnMouseDown, - sortProjectsForSidebar, - useSidebarRowSubscriptionLease, - setFileDragActive, - useThreadJumpHintsVisible, - ThreadStatusPill, -} from "./Sidebar.logic"; -import { sortThreads } from "@t3tools/client-runtime/state/thread-sort"; -import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; -import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useIsMobile } from "~/hooks/useMediaQuery"; -import { CommandDialogTrigger } from "./ui/command"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; -import { appAtomRegistry } from "../rpc/atomRegistry"; -import { canShareWithGuestAtom } from "./chat/guestShareAvailability"; -import { openGuestShareDialog } from "./chat/guestShareDialogState"; -import { - derivePhysicalProjectKey, - deriveProjectGroupingOverrideKey, - getProjectOrderKey, - selectProjectGroupingSettings, -} from "@t3tools/client-runtime/state/project-grouping"; -import type { SidebarThreadSummary } from "../types"; -import { - buildPhysicalToLogicalProjectKeyMap, - buildSidebarProjectSnapshots, - type SidebarProjectGroupMember, - type SidebarProjectSnapshot, -} from "../sidebarProjectGrouping"; -import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; -import { useThreadRouteTarget } from "../hooks/useThreadRouteTarget"; -import { type SidebarRouteReveal, useActiveRowReveal } from "./sidebar/routeReveal"; -const SIDEBAR_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", - manual: "Manual", -}; -const SIDEBAR_THREAD_SORT_LABELS: Record = { - updated_at: "Last user message", - created_at: "Created at", -}; -const SIDEBAR_LIST_ANIMATION_OPTIONS = { - duration: 180, - easing: "ease-out", -} as const; -const EMPTY_THREAD_JUMP_LABELS = new Map(); -const PROJECT_GROUPING_MODE_LABELS: Record = { - repository: "Group by repository", - repository_path: "Group by repository path", - separate: "Keep separate", -}; -const SIDEBAR_ICON_ACTION_BUTTON_CLASS = - "inline-flex h-6 min-w-6 cursor-pointer items-center justify-center rounded-md px-0.75 text-icon-muted hover:text-foreground focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring"; - -function SidebarThreadDetailPrewarmer({ threadRef }: { readonly threadRef: ScopedThreadRef }) { - useEnvironmentThread(threadRef.environmentId, threadRef.threadId); - return null; -} - -function clampSidebarThreadPreviewCount(value: number): SidebarThreadPreviewCount { - return Math.min( - MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - Math.max(MIN_SIDEBAR_THREAD_PREVIEW_COUNT, value), - ) as SidebarThreadPreviewCount; -} - -function formatProjectMemberActionLabel( - member: SidebarProjectGroupMember, - groupedProjectCount: number, -): string { - if (groupedProjectCount <= 1) { - return member.title; - } - - return member.environmentLabel - ? `${member.environmentLabel}: ${member.workspaceRoot}` - : member.workspaceRoot; -} - -function projectExpansionPreferenceKeys(project: SidebarProjectSnapshot): string[] { - return [ - project.projectKey, - ...project.memberProjects.map((member) => member.physicalProjectKey), - ...project.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), - ]; -} - -function projectGroupingModeDescription(mode: SidebarProjectGroupingMode): string { - switch (mode) { - case "repository": - return "Projects from the same repository share one sidebar row."; - case "repository_path": - return "Projects group only when both the repository and repo-relative path match."; - case "separate": - return "Every project path gets its own sidebar row."; - } -} - -function buildThreadJumpLabelMap(input: { - keybindings: ResolvedKeybindingsConfig; - platform: string; - terminalOpen: boolean; - threadJumpCommandByKey: ReadonlyMap< - string, - NonNullable> - >; -}): ReadonlyMap { - if (input.threadJumpCommandByKey.size === 0) { - return EMPTY_THREAD_JUMP_LABELS; - } - - const shortcutLabelOptions = { - platform: input.platform, - context: { - terminalFocus: false, - terminalOpen: input.terminalOpen, - }, - } as const; - const mapping = new Map(); - for (const [threadKey, command] of input.threadJumpCommandByKey) { - const label = shortcutLabelForCommand(input.keybindings, command, shortcutLabelOptions); - if (label) { - mapping.set(threadKey, label); - } - } - return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; -} - -interface SidebarThreadRowProps { - navigationParent?: string | undefined; - thread: SidebarThreadSummary; - orderedProjectThreadKeys: readonly string[]; - isActive: boolean; - openPullRequestsInRightPanel: boolean; - jumpLabel: string | null; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => Promise; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: ( - event: React.MouseEvent, - prUrl: string, - threadRef?: ScopedThreadRef, - ) => boolean; - onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; -} - -function checkTaskPermission(environmentId: EnvironmentId): boolean { - if (readEnvironmentScope(environmentId, AuthOrchestrationOperateScope)) return true; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Action unavailable", - description: ORCHESTRATION_OPERATE_DENIED_MESSAGE, - }), - ); - return false; -} - -// The active row reports itself, so a route change scrolls to it once it mounts. -const RevealActiveRowContext = createContext<(row: HTMLElement | null, threadKey: string) => void>( - () => {}, -); - -const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowProps) { - const { - orderedProjectThreadKeys, - isActive, - openPullRequestsInRightPanel, - jumpLabel, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - handleThreadClick, - navigateToThread, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - onFileDropThreads, - thread, - } = props; - const canOperateThread = useEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope); - const canOperatePreview = useEnvironmentScope(thread.environmentId, AuthPreviewOperateScope); - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - const [isFileDragOver, setIsFileDragOver] = useState(false); - const revealActiveRow = useContext(RevealActiveRowContext); - const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(isActive); - const localLastVisitedAt = useUiStateStore((state) => state.threadLastVisitedAtById[threadKey]); - const lastVisitedAt = resolveThreadLastVisitedAt(thread.lastVisitedAt, localLastVisitedAt); - const isSelected = useThreadSelectionStore((state) => state.selectedThreadKeys.has(threadKey)); - const runningTerminalIds = useThreadRunningTerminalIds({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const isMobile = useIsMobile(); - const discoveredPorts = useThreadDiscoveredPorts({ - environmentId: thread.environmentId, - threadId: thread.id, - }); - const openPreview = useAtomCommand(previewEnvironment.open, { - reportFailure: false, - }); - const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = thread.environmentId !== primaryEnvironmentId; - const remoteEnvLabel = environment?.label ?? null; - const remoteMachine = resolveEnvironmentMachineKind(environment?.serverConfig ?? null); - const isDesktopLocalThread = - environment !== null && isDesktopLocalConnectionTarget(environment.entry.target); - const threadEnvironmentLabel = isRemoteThread - ? (remoteEnvLabel ?? (isDesktopLocalThread ? "Local" : "Remote")) - : null; - const isThreadRunning = !threadRuntimeCanArchive(thread.runtime); - const threadStatus = resolveThreadStatusPill({ - thread: { - ...thread, - lastVisitedAt, - }, - }); - const linkedPullRequestStatus = useLinkedThreadPullRequest( - thread.environmentId, - thread.linkedPullRequest, - leaseLiveStatus, - thread.pullRequests, - thread.branchPullRequest, - ); - const pr = linkedPullRequestStatus?.pr ?? null; - const supportsMultiplePullRequests = useSupportsMultiplePullRequests(thread.environmentId); - // Hooks must precede this ref's plain uses for the compiler to cache it. - const threadRef = scopeThreadRef(thread.environmentId, thread.id); - const fileDropHandlers = makeWorkspaceFileDropHandlers({ - setDragActive: (active) => setFileDragActive(setIsFileDragOver, active), - addFiles: (files) => { - onFileDropThreads(threadRef, files); - }, - addFolders: () => {}, - }); - const isHighlighted = isActive || isSelected; - const handleOpenDiscoveredPort = (event: React.MouseEvent) => { - const port = discoveredPorts[0]; - if (!port || !canOperatePreview) return; - event.preventDefault(); - event.stopPropagation(); - navigateToThread(threadRef); - void (async () => { - const result = await openDiscoveredPort({ threadRef, port, openPreview }); - if (result._tag === "Success" || isAtomCommandInterrupted(result)) { - return; - } - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Unable to open preview", - description: error instanceof Error ? error.message : "The preview could not be opened.", - }), - ); - })(); - }; - const currentLinkedPr = supportsMultiplePullRequests - ? resolveThreadCurrentPullRequestLink(thread.pullRequests) - : null; - const prStatus = prStatusIndicator(pr, linkedPullRequestStatus?.sourceControlProvider); - const isConfirmingArchive = - canOperateThread && confirmingArchiveThreadKey === threadKey && !isThreadRunning; - const threadMetaClassName = isConfirmingArchive - ? "pointer-events-none opacity-0" - : canOperateThread && !isThreadRunning - ? "pointer-events-none transition-opacity duration-150 max-sm:pr-6 group-hover/menu-sub-item:opacity-0 group-focus-within/menu-sub-item:opacity-0" - : "pointer-events-none"; - const clearConfirmingArchive = () => { - setConfirmingArchiveThreadKey((current) => (current === threadKey ? null : current)); - }; - const handleMouseLeave = () => { - clearConfirmingArchive(); - }; - const handleBlurCapture = (event: React.FocusEvent) => { - const currentTarget = event.currentTarget; - requestAnimationFrame(() => { - if (currentTarget.contains(document.activeElement)) { - return; - } - clearConfirmingArchive(); - }); - }; - const handleRowClick = (event: React.MouseEvent) => { - handleThreadClick(event, threadRef, orderedProjectThreadKeys); - }; - const handleRowDoubleClick = (event: React.MouseEvent) => { - if (!readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope)) return; - if (renamingThreadKey === threadKey) return; - // Mobile navigation closes the sidebar on the first tap, so rename stays in the context menu. - if (isMobile) return; - if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return; - if ((event.target as HTMLElement).closest("button, a")) return; - event.preventDefault(); - startThreadRename(threadKey, thread.title); - }; - const handleRowKeyDown = (event: React.KeyboardEvent) => { - if (event.target !== event.currentTarget) return; - if ( - event.key === "F2" && - renamingThreadKey !== threadKey && - readEnvironmentScope(thread.environmentId, AuthOrchestrationOperateScope) - ) { - event.preventDefault(); - startThreadRename(threadKey, thread.title); - return; - } - if (event.key !== "Enter" && event.key !== " ") return; - event.preventDefault(); - navigateToThread(threadRef); - }; - const handleRowContextMenu = (event: React.MouseEvent) => { - event.preventDefault(); - const hasSelection = useThreadSelectionStore.getState().hasSelection(); - if (hasSelection && isSelected) { - void (async () => { - const result = await settlePromise(() => - handleMultiSelectContextMenu({ - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - return; - } - - if (hasSelection) { - clearSelection(); - } - void (async () => { - const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }), - ); - if (result._tag === "Failure") { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Thread action failed", - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); - } - })(); - }; - const handlePrClick = (event: React.MouseEvent) => { - const url = prStatus?.url ?? currentLinkedPr?.url; - if (!url) return; - const openedInRightPanel = openPrLink( - event, - url, - openPullRequestsInRightPanel ? threadRef : undefined, - ); - if (openedInRightPanel && openPullRequestsInRightPanel && !isActive) { - navigateToThread(threadRef); - } - }; - const handleRenameInputRef = (element: HTMLInputElement | null) => { - if (element && renamingInputRef.current !== element) { - renamingInputRef.current = element; - element.focus(); - element.select(); - } - }; - const handleRenameInputChange = (event: React.ChangeEvent) => { - setRenamingTitle(event.target.value); - }; - const handleRenameInputKeyDown = (event: React.KeyboardEvent) => { - event.stopPropagation(); - if (event.key === "Enter") { - event.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(threadRef, renamingTitle, thread.title); - } else if (event.key === "Escape") { - event.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }; - const handleRenameInputBlur = () => { - if (!renamingCommittedRef.current) { - void commitRename(threadRef, renamingTitle, thread.title); - } - }; - // Stop dblclick propagation so word selection cannot restart rename and discard edits. - const handleRenameInputClick = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - const handleConfirmArchiveRef = (element: HTMLButtonElement | null) => { - if (element) { - confirmArchiveButtonRefs.current.set(threadKey, element); - } else { - confirmArchiveButtonRefs.current.delete(threadKey); - } - }; - const stopPropagationOnPointerDown = (event: React.PointerEvent) => { - event.stopPropagation(); - }; - const handleConfirmArchiveClick = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - clearConfirmingArchive(); - void attemptArchiveThread(threadRef); - }; - const handleStartArchiveConfirmation = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - if (!checkTaskPermission(thread.environmentId)) return; - setConfirmingArchiveThreadKey(threadKey); - requestAnimationFrame(() => { - confirmArchiveButtonRefs.current.get(threadKey)?.focus(); - }); - }; - const handleArchiveImmediateClick = (event: React.MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - void attemptArchiveThread(threadRef); - }; - - return ( - -
    revealActiveRow(node, threadKey) : undefined} - role="treeitem" - tabIndex={-1} - data-sidebar-row={threadKey} - data-sidebar-parent={props.navigationParent} - aria-current={isActive ? "page" : undefined} - data-active={isActive} - data-slot="sidebar-menu-sub-button" - data-sidebar="menu-sub-button" - data-size="sm" - data-testid={`thread-row-${thread.id}`} - className={cn( - "relative isolate flex h-8 w-full min-w-0 cursor-pointer select-none items-center gap-2 overflow-hidden rounded-md px-2 text-left text-xs outline-hidden focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring group-data-[collapsible=icon]:hidden [&>span:last-child]:truncate [&>svg:not([class*='size-'])]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-muted-foreground", - isActive - ? "bg-sidebar-row-active font-medium text-sidebar-foreground hover:bg-sidebar-row-active" - : isSelected - ? "bg-sidebar-row-selected text-sidebar-foreground hover:bg-sidebar-row-active" - : "text-sidebar-muted-foreground/80 hover:bg-sidebar-row-hover hover:text-sidebar-foreground", - isFileDragOver && "ring-1 ring-inset ring-primary/70", - )} - onClick={handleRowClick} - onDoubleClick={handleRowDoubleClick} - onKeyDown={handleRowKeyDown} - onContextMenu={handleRowContextMenu} - > -
    - {prStatus && pr && ( - - event.stopPropagation()} - onClick={handlePrClick} - > - - - } - /> - - - - - )} - {!pr && currentLinkedPr ? ( - event.stopPropagation()} - onClick={handlePrClick} - className="text-muted-foreground" - aria-label={`PR #${currentLinkedPr.number}, status pending`} - > - - - ) : null} - {threadStatus && } - {canOperateThread && renamingThreadKey === threadKey ? ( - - ) : ( - - - {thread.title} - - } - /> - {thread.title} - - )} -
    -
    - {canOperatePreview && discoveredPorts.length > 0 && ( - - - } - > - - - - Open localhost:{discoveredPorts[0]?.port} - {discoveredPorts.length > 1 ? ` (+${discoveredPorts.length - 1})` : ""} - - - )} - - -
    - {isConfirmingArchive ? ( - - ) : canOperateThread && !isThreadRunning ? ( - appSettingsConfirmThreadArchive ? ( -
    - -
    - ) : ( - - - -
    - } - /> - Archive - - ) - ) : null} - - - {isRemoteThread && !isDesktopLocalThread && ( - - - } - > - - - {threadEnvironmentLabel} - - )} - {jumpLabel ? ( - - - } - > - {jumpLabel} - - {jumpLabel} - - ) : ( - - - - )} - - -
    -
    -
    - - ); -}); - -interface SidebarProjectThreadListProps { - projectKey: string; - projectExpanded: boolean; - hasOverflowingThreads: boolean; - hiddenThreadStatus: ThreadStatusPill | null; - orderedProjectThreadKeys: readonly string[]; - renderedThreads: readonly SidebarThreadSummary[]; - showEmptyThreadState: boolean; - shouldShowThreadPanel: boolean; - isThreadListExpanded: boolean; - activeRouteThreadKey: string | null; - openPullRequestsInRightPanel: boolean; - threadJumpLabelByKey: ReadonlyMap; - appSettingsConfirmThreadArchive: boolean; - renamingThreadKey: string | null; - renamingTitle: string; - setRenamingTitle: (title: string) => void; - startThreadRename: (threadKey: string, title: string) => void; - renamingInputRef: React.RefObject; - renamingCommittedRef: React.RefObject; - confirmingArchiveThreadKey: string | null; - setConfirmingArchiveThreadKey: React.Dispatch>; - confirmArchiveButtonRefs: React.RefObject>; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - handleThreadClick: ( - event: React.MouseEvent, - threadRef: ScopedThreadRef, - orderedProjectThreadKeys: readonly string[], - ) => void; - navigateToThread: (threadRef: ScopedThreadRef) => Promise; - onFileDropThreads: (threadRef: ScopedThreadRef, files: File[]) => void; - handleMultiSelectContextMenu: (position: { x: number; y: number }) => Promise; - handleThreadContextMenu: ( - threadRef: ScopedThreadRef, - position: { x: number; y: number }, - ) => Promise; - clearSelection: () => void; - commitRename: ( - threadRef: ScopedThreadRef, - newTitle: string, - originalTitle: string, - ) => Promise; - cancelRename: () => void; - attemptArchiveThread: (threadRef: ScopedThreadRef) => Promise; - openPrLink: ( - event: React.MouseEvent, - prUrl: string, - threadRef?: ScopedThreadRef, - ) => boolean; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; -} - -const SidebarProjectThreadList = memo(function SidebarProjectThreadList( - props: SidebarProjectThreadListProps, -) { - const { - projectKey, - projectExpanded, - hasOverflowingThreads, - hiddenThreadStatus, - orderedProjectThreadKeys, - renderedThreads, - showEmptyThreadState, - shouldShowThreadPanel, - isThreadListExpanded, - activeRouteThreadKey, - openPullRequestsInRightPanel, - threadJumpLabelByKey, - appSettingsConfirmThreadArchive, - renamingThreadKey, - renamingTitle, - setRenamingTitle, - startThreadRename, - renamingInputRef, - renamingCommittedRef, - confirmingArchiveThreadKey, - setConfirmingArchiveThreadKey, - confirmArchiveButtonRefs, - attachThreadListAutoAnimateRef, - handleThreadClick, - navigateToThread, - onFileDropThreads, - handleMultiSelectContextMenu, - handleThreadContextMenu, - clearSelection, - commitRename, - cancelRename, - attemptArchiveThread, - openPrLink, - expandThreadListForProject, - collapseThreadListForProject, - } = props; - const showMoreButtonRender = - - } - /> - - {newThreadShortcutLabel ? `New thread (${newThreadShortcutLabel})` : "New thread"} - - - - - - - { - if (!open) { - closeProjectRenameDialog(); - } - }} - > - - - Rename project - - {projectRenameTarget - ? `Update the title for ${projectRenameTarget.workspaceRoot}.` - : "Update the project title."} - - - -
    - Project title - setProjectRenameTitle(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void submitProjectRename(); - } - }} - /> -
    - {projectRenameTarget?.environmentLabel ? ( -

    - Environment: {projectRenameTarget.environmentLabel} -

    - ) : null} - {!canRenameProject ? ( -

    - This connection cannot change projects. -

    - ) : null} -
    - - - - -
    -
    - - { - if (!open) { - closeProjectGroupingDialog(); - } - }} - > - - - Project grouping - - {projectGroupingTarget - ? `Choose how ${projectGroupingTarget.workspaceRoot} should be grouped in the sidebar.` - : "Choose how this project should be grouped in the sidebar."} - - - -
    - Grouping rule - -
    -

    - {projectGroupingSelection === "inherit" - ? projectGroupingModeDescription(projectGroupingSettings.sidebarProjectGroupingMode) - : projectGroupingModeDescription(projectGroupingSelection)} -

    -
    - - - - -
    -
    - - ); -}); - -const SidebarProjectListRow = memo(function SidebarProjectListRow(props: SidebarProjectItemProps) { - return ( - - - - ); -}); - -function LocalSecondaryStatus() { - const { environments } = useEnvironments(); - // Poll desktop-local backends because the bridge has no change event. - const secondaries = useDesktopLocalBootstraps(); - - const localEnvByUrl = (() => { - const map = new Map(); - for (const environment of environments) { - if ( - isDesktopLocalConnectionTarget(environment.entry.target) && - environment.displayUrl !== null - ) { - map.set(environment.displayUrl, { - phase: environment.connection.phase, - error: environment.connection.error, - }); - } - } - return map; - })(); - - const connecting: string[] = []; - const failed: Array<{ label: string; error: string | null }> = []; - for (const bootstrap of secondaries) { - const env = - bootstrap.httpBaseUrl !== null ? localEnvByUrl.get(bootstrap.httpBaseUrl) : undefined; - if (env?.phase === "connected") { - continue; - } - if (env?.phase === "error") { - failed.push({ label: bootstrap.label, error: env.error }); - continue; - } - connecting.push(bootstrap.label); - } - - if (connecting.length === 0 && failed.length === 0) { - return null; - } - - return ( - - {connecting.length > 0 ? ( - - - Connecting {connecting.join(", ")} - - ) : null} - {failed.length > 0 ? ( - - - Couldn't connect {failed.map((entry) => entry.label).join(", ")} - - {failed - .map((entry) => entry.error) - .filter(Boolean) - .join("; ") || "The backend didn't respond."} - - - ) : null} - - ); -} - -type SortableProjectHandleProps = Pick< - ReturnType, - "attributes" | "listeners" | "setActivatorNodeRef" ->; - -function ProjectSortMenu({ - projectSortOrder, - threadSortOrder, - threadPreviewCount, - onProjectSortOrderChange, - onThreadSortOrderChange, - onThreadPreviewCountChange, -}: { - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; - onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; -}) { - const handleThreadPreviewCountChange = (nextValue: number | null) => { - if (nextValue === null) { - return; - } - - const clampedValue = clampSidebarThreadPreviewCount(nextValue); - if (clampedValue !== threadPreviewCount) { - onThreadPreviewCountChange(clampedValue); - } - }; - - return ( - - - } - /> - } - > - - - Sidebar options - - - -
    - Sort projects -
    - { - onProjectSortOrderChange(value as SidebarProjectSortOrder); - }} - > - {(Object.entries(SIDEBAR_SORT_LABELS) as Array<[SidebarProjectSortOrder, string]>).map( - ([value, label]) => ( - - {label} - - ), - )} - -
    - -
    - Sort threads -
    - { - onThreadSortOrderChange(value as SidebarThreadSortOrder); - }} - > - {( - Object.entries(SIDEBAR_THREAD_SORT_LABELS) as Array<[SidebarThreadSortOrder, string]> - ).map(([value, label]) => ( - - {label} - - ))} - -
    - -
    - Visible threads -
    -
    - - - - { - event.stopPropagation(); - }} - /> - - - -
    -
    -
    -
    - ); -} - -function SortableProjectItem({ - projectId, - disabled = false, - children, -}: { - projectId: string; - disabled?: boolean; - children: (handleProps: SortableProjectHandleProps) => React.ReactNode; -}) { - const { - attributes, - listeners, - setActivatorNodeRef, - setNodeRef, - transform, - transition, - isDragging, - isOver, - } = useSortable({ id: projectId, disabled }); - return ( -
  • - {children({ attributes, listeners, setActivatorNodeRef })} -
  • - ); -} - -interface SidebarProjectsContentProps { - showArm64IntelBuildWarning: boolean; - arm64IntelBuildWarningDescription: string | null; - desktopUpdateButtonAction: "download" | "install" | "none"; - desktopUpdateButtonDisabled: boolean; - desktopUpdateActionPending: boolean; - handleDesktopUpdateButtonClick: () => void; - projectSortOrder: SidebarProjectSortOrder; - threadSortOrder: SidebarThreadSortOrder; - threadPreviewCount: SidebarThreadPreviewCount; - openAddProject: () => void; - isManualProjectSorting: boolean; - projectDnDSensors: ReturnType; - projectCollisionDetection: CollisionDetection; - handleProjectDragStart: (event: DragStartEvent) => void; - handleProjectDragEnd: (event: DragEndEvent) => void; - handleProjectDragCancel: (event: DragCancelEvent) => void; - handleNewThread: ReturnType; - archiveThread: ReturnType["archiveThread"]; - deleteThread: ReturnType["deleteThread"]; - markThreadUnread: ReturnType["markThreadUnread"]; - sortedProjects: readonly SidebarProjectSnapshot[]; - sortedProjectKeys: string[]; - expandedThreadListsByProject: ReadonlySet; - activeRouteProjectKey: string | null; - routeThreadKey: string | null; - openPullRequestsInRightPanel: boolean; - newThreadShortcutLabel: string | null; - commandPaletteShortcutLabel: string | null; - threadJumpLabelByKey: ReadonlyMap; - attachThreadListAutoAnimateRef: (node: HTMLElement | null) => void; - expandThreadListForProject: (projectKey: string) => void; - collapseThreadListForProject: (projectKey: string) => void; - dragInProgressRef: React.RefObject; - suppressProjectClickAfterDragRef: React.RefObject; - suppressProjectClickForContextMenuRef: React.RefObject; - attachProjectListAutoAnimateRef: (node: HTMLElement | null) => void; - projectsLength: number; -} - -const SidebarProjectsContent = memo(function SidebarProjectsContent( - props: SidebarProjectsContentProps, -) { - const { - showArm64IntelBuildWarning, - arm64IntelBuildWarningDescription, - desktopUpdateButtonAction, - desktopUpdateButtonDisabled, - desktopUpdateActionPending, - handleDesktopUpdateButtonClick, - projectSortOrder, - threadSortOrder, - threadPreviewCount, - openAddProject, - isManualProjectSorting, - projectDnDSensors, - projectCollisionDetection, - handleProjectDragStart, - handleProjectDragEnd, - handleProjectDragCancel, - handleNewThread, - archiveThread, - deleteThread, - markThreadUnread, - sortedProjects, - sortedProjectKeys, - expandedThreadListsByProject, - activeRouteProjectKey, - routeThreadKey, - openPullRequestsInRightPanel, - newThreadShortcutLabel, - commandPaletteShortcutLabel, - threadJumpLabelByKey, - attachThreadListAutoAnimateRef, - expandThreadListForProject, - collapseThreadListForProject, - dragInProgressRef, - suppressProjectClickAfterDragRef, - suppressProjectClickForContextMenuRef, - attachProjectListAutoAnimateRef, - projectsLength, - } = props; - - const attachTreeRef = (node: HTMLUListElement | null) => { - attachProjectListAutoAnimateRef(node); - const dispose = attachSidebarTree(node); - return () => { - dispose?.(); - attachProjectListAutoAnimateRef(null); - }; - }; - const handleProjectSortOrderChange = (sortOrder: SidebarProjectSortOrder) => { - updateClientSettings({ sidebarProjectSortOrder: sortOrder }); - }; - const handleThreadSortOrderChange = (sortOrder: SidebarThreadSortOrder) => { - updateClientSettings({ sidebarThreadSortOrder: sortOrder }); - }; - const handleThreadPreviewCountChange = (count: SidebarThreadPreviewCount) => { - updateClientSettings({ sidebarThreadPreviewCount: count }); - }; - - return ( - - - - } - > - - Search - {commandPaletteShortcutLabel ? {commandPaletteShortcutLabel} : null} - - - - - } - > - {showArm64IntelBuildWarning && arm64IntelBuildWarningDescription ? ( - - - - Intel build on Apple Silicon - {arm64IntelBuildWarningDescription} - {desktopUpdateButtonAction !== "none" ? ( - - - - ) : null} - - - ) : null} - - -
    - Projects -
    - - - - } - > - - - Add project - -
    -
    - - {isManualProjectSorting ? ( - - - - {sortedProjects.map((project) => ( - - {(dragHandleProps) => ( - - )} - - ))} - - - - ) : ( - - {sortedProjects.map((project) => ( - - ))} - - )} - - {projectsLength === 0 && ( -
    No projects yet
    - )} -
    -
    - ); -}); - -const PROJECT_POINTER_SENSOR_OPTIONS = { activationConstraint: { distance: 6 } }; -const PROJECT_DND_MODIFIERS = [restrictToVerticalAxis, restrictToFirstScrollableAncestor]; - -const projectCollisionDetection: CollisionDetection = (args) => { - const pointerCollisions = pointerWithin(args); - if (pointerCollisions.length > 0) { - return pointerCollisions; - } - - return closestCorners(args); -}; - -function openAddProjectCommandPalette() { - openCommandPalette({ open: "add-project" }); -} - -// Separate hooks cache real inputs; later sidebar hooks would invalidate every row's props. -function useLegacySidebarProjects() { - const projects = useProjects(); - const projectOrder = useUiStateStore((store) => store.projectOrder); - const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const { environments } = useEnvironments(); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const environmentLabelById = new Map( - environments.map((environment) => [environment.environmentId, environment.label] as const), - ); - const desktopLocalEnvironmentIds = new Set( - environments - .filter((environment) => isDesktopLocalConnectionTarget(environment.entry.target)) - .map((environment) => environment.environmentId), - ); - const wslEnvironmentIds = new Set( - environments - .filter((environment) => isWslConnectionTarget(environment.entry.target)) - .map((environment) => environment.environmentId), - ); - const orderedProjects = orderItemsByPreferredIds({ - items: projects, - preferredIds: projectOrder, - getId: getProjectOrderKey, - getPreferenceIds: (project) => [ - getProjectOrderKey(project), - legacyProjectCwdPreferenceKey(project.workspaceRoot), - ], - }); - const physicalToLogicalKey = buildPhysicalToLogicalProjectKeyMap({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - }); - const projectPhysicalKeyByScopedRef = new Map( - orderedProjects.map((project) => [ - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - derivePhysicalProjectKey(project), - ]), - ); - const sidebarProjects: SidebarProjectSnapshot[] = buildSidebarProjectSnapshots({ - projects: orderedProjects, - settings: projectGroupingSettings, - primaryEnvironmentId, - resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, - isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), - isWslEnvironment: (environmentId) => wslEnvironmentIds.has(environmentId), - }); - return { - projectsLength: projects.length, - orderedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjects, - }; -} - -// Re-sort on thread changes without rebuilding project snapshots used as row keys. -function useSortedLegacySidebarProjects(input: { - sidebarProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - projectPhysicalKeyByScopedRef: ReadonlyMap; - sidebarProjectSortOrder: SidebarProjectSortOrder; -}) { - const { - sidebarProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjectSortOrder, - } = input; - const sidebarThreads = useThreadShells(); - const delegatedUnderParent = useDelegatedUnderParent(); - const sidebarProjectByKey = new Map( - sidebarProjects.map((project) => [project.projectKey, project] as const), - ); - const sortableProjects = sidebarProjects.map((project) => ({ - ...project, - id: project.projectKey, - })); - const sortableThreads = filterSidebarV2VisibleThreads( - sidebarThreads, - null, - delegatedUnderParent, - ).map((thread) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return { - ...thread, - projectId: (physicalToLogicalKey.get(physicalKey) ?? physicalKey) as ProjectId, - }; - }); - const sortedProjects = sortProjectsForSidebar( - sortableProjects, - sortableThreads, - sidebarProjectSortOrder, - ).flatMap((project) => { - const resolvedProject = sidebarProjectByKey.get(project.id); - return resolvedProject ? [resolvedProject] : []; - }); - return { - sortedProjects, - sortedProjectKeys: sortedProjects.map((project) => project.projectKey), - }; -} - -function useLegacySidebarThreads(input: { - sortedProjects: readonly SidebarProjectSnapshot[]; - physicalToLogicalKey: ReadonlyMap; - projectPhysicalKeyByScopedRef: ReadonlyMap; - routeThreadKey: string | null; - sidebarThreadSortOrder: SidebarThreadSortOrder; - sidebarThreadPreviewCount: number; - expandedThreadListsByProject: ReadonlySet; -}) { - const { - sortedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - routeThreadKey, - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - } = input; - const sidebarThreads = useThreadShells(); - const delegatedUnderParent = useDelegatedUnderParent(); - const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); - const sidebarThreadByKey = new Map( - sidebarThreads.map( - (thread) => - [scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)), thread] as const, - ), - ); - const logicalProjectKeyForThread = (thread: SidebarThreadSummary) => { - const physicalKey = - projectPhysicalKeyByScopedRef.get( - scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)), - ) ?? scopedProjectKey(scopeProjectRef(thread.environmentId, thread.projectId)); - return physicalToLogicalKey.get(physicalKey) ?? physicalKey; - }; - const activeRouteThread = routeThreadKey ? sidebarThreadByKey.get(routeThreadKey) : undefined; - const activeRouteProjectKey = activeRouteThread - ? logicalProjectKeyForThread(activeRouteThread) - : null; - const threadsByProjectKey = new Map(); - for (const thread of sidebarThreads) { - const logicalKey = logicalProjectKeyForThread(thread); - const existing = threadsByProjectKey.get(logicalKey); - if (existing) { - existing.push(thread); - } else { - threadsByProjectKey.set(logicalKey, [thread]); - } - } - const visibleSidebarThreadKeys = sortedProjects.flatMap((project) => { - const projectThreads = sortThreads( - filterSidebarV2VisibleThreads( - threadsByProjectKey.get(project.projectKey) ?? [], - null, - delegatedUnderParent, - ), - sidebarThreadSortOrder, - ); - const projectExpanded = resolveProjectExpanded( - projectExpandedById, - projectExpansionPreferenceKeys(project), - ); - const activeThreadKey = routeThreadKey ?? undefined; - const pinnedCollapsedThread = - !projectExpanded && activeThreadKey - ? (projectThreads.find( - (thread) => - scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) === activeThreadKey, - ) ?? null) - : null; - const shouldShowThreadPanel = projectExpanded || pinnedCollapsedThread !== null; - if (!shouldShowThreadPanel) { - return []; - } - const isThreadListExpanded = expandedThreadListsByProject.has(project.projectKey); - const hasOverflowingThreads = projectThreads.length > sidebarThreadPreviewCount; - const previewThreads = - isThreadListExpanded || !hasOverflowingThreads - ? projectThreads - : projectThreads.slice(0, sidebarThreadPreviewCount); - if (pinnedCollapsedThread) { - return [ - scopedThreadKey( - scopeThreadRef(pinnedCollapsedThread.environmentId, pinnedCollapsedThread.id), - ), - ]; - } - return projectThreads.flatMap((thread, index) => { - const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); - return index < previewThreads.length || threadKey === activeThreadKey ? [threadKey] : []; - }); - }); - const threadJumpCommandByKey = new Map< - string, - NonNullable> - >(); - for (const [visibleThreadIndex, threadKey] of visibleSidebarThreadKeys.entries()) { - const jumpCommand = threadJumpCommandForIndex(visibleThreadIndex); - if (!jumpCommand) { - break; - } - threadJumpCommandByKey.set(threadKey, jumpCommand); - } - const prewarmedSidebarThreadRefs = getSidebarThreadIdsToPrewarm(visibleSidebarThreadKeys).flatMap( - (threadKey) => { - const ref = parseScopedThreadKey(threadKey); - return ref ? [ref] : []; - }, - ); - return { - sidebarThreadByKey, - activeRouteProjectKey, - visibleSidebarThreadKeys, - threadJumpCommandByKey, - threadJumpThreadKeys: [...threadJumpCommandByKey.keys()], - prewarmedSidebarThreadRefs, - }; -} - -function clearThreadSelectionOnOutsideMouseDown(event: globalThis.MouseEvent) { - const selection = useThreadSelectionStore.getState(); - if (!selection.hasSelection()) return; - const target = event.target instanceof HTMLElement ? event.target : null; - if (shouldClearThreadSelectionOnMouseDown(target)) selection.clearSelection(); -} - -export default function LegacySidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { - const reorderProjects = useUiStateStore((store) => store.reorderProjects); - const navigate = useNavigate(); - const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); - const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); - const handleNewThread = useNewThreadHandler(); - const { archiveThread, deleteThread, markThreadUnread } = useThreadActions(); - const { isMobile, setOpenMobile } = useSidebar(); - const routeTarget = useThreadRouteTarget(); - const routeDraftThread = useComposerDraftStore((store) => - routeTarget?.kind === "draft" ? store.getDraftSession(routeTarget.draftId) : null, - ); - const routeThreadRef = resolveActiveThreadRouteRef(routeTarget, routeDraftThread); - const routeThreadKey = routeThreadRef ? scopedThreadKey(routeThreadRef) : null; - const routeTerminalOpen = useTerminalUiStateStore((state) => - routeThreadRef - ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen - : false, - ); - const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< - ReadonlySet - >(() => new Set()); - const dragInProgressRef = useRef(false); - const suppressProjectClickAfterDragRef = useRef(false); - const suppressProjectClickForContextMenuRef = useRef(false); - const desktopUpdateState = useDesktopUpdateState(); - const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false); - const clearSelection = useThreadSelectionStore((s) => s.clearSelection); - const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); - const platform = navigator.platform; - const shortcutModifiers = useShortcutModifierState(); - const terminalFocused = useTerminalFocus(); - const { - projectsLength, - orderedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjects, - } = useLegacySidebarProjects(); - const { sortedProjects, sortedProjectKeys } = useSortedLegacySidebarProjects({ - sidebarProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - sidebarProjectSortOrder, - }); - const { - sidebarThreadByKey, - activeRouteProjectKey, - visibleSidebarThreadKeys, - threadJumpCommandByKey, - threadJumpThreadKeys, - prewarmedSidebarThreadRefs, - } = useLegacySidebarThreads({ - sortedProjects, - physicalToLogicalKey, - projectPhysicalKeyByScopedRef, - routeThreadKey, - sidebarThreadSortOrder, - sidebarThreadPreviewCount, - expandedThreadListsByProject, - }); - const revealActiveRow = useActiveRowReveal(routeReveal, routeThreadKey); - const newThreadShortcutLabelOptions = { - platform, - context: { - terminalFocus: false, - terminalOpen: false, - }, - }; - const newThreadShortcutLabel = - shortcutLabelForCommand(keybindings, "chat.newLocal", newThreadShortcutLabelOptions) ?? - shortcutLabelForCommand(keybindings, "chat.new", newThreadShortcutLabelOptions); - - const navigateToThread = (threadRef: ScopedThreadRef) => { - if (useThreadSelectionStore.getState().selectedThreadKeys.size > 0) { - clearSelection(); - } - setSelectionAnchor(scopedThreadKey(threadRef)); - if (isMobile) { - setOpenMobile(false); - } - void navigate({ - to: "/$environmentId/$threadId", - params: buildThreadRouteParams(threadRef), - }); - }; - - const projectDnDSensors = useSensors(useSensor(PointerSensor, PROJECT_POINTER_SENSOR_OPTIONS)); - - const handleProjectDragEnd = useStableCallback((event: DragEndEvent) => { - if (sidebarProjectSortOrder !== "manual") { - dragInProgressRef.current = false; - return; - } - dragInProgressRef.current = false; - const { active, over } = event; - if (!over || active.id === over.id) return; - const activeProject = sidebarProjects.find((project) => project.projectKey === active.id); - const overProject = sidebarProjects.find((project) => project.projectKey === over.id); - if (!activeProject || !overProject) return; - const activeMemberKeys = activeProject.memberProjects.map( - (member) => member.physicalProjectKey, - ); - const overMemberKeys = overProject.memberProjects.map((member) => member.physicalProjectKey); - reorderProjects(orderedProjects.map(getProjectOrderKey), activeMemberKeys, overMemberKeys); - }); - - const handleProjectDragStart = useStableCallback((_event: DragStartEvent) => { - if (sidebarProjectSortOrder !== "manual") { - return; - } - dragInProgressRef.current = true; - suppressProjectClickAfterDragRef.current = true; - }); - - const handleProjectDragCancel = (_event: DragCancelEvent) => { - dragInProgressRef.current = false; - }; - - const animatedProjectListsRef = useRef(new WeakSet()); - const attachProjectListAutoAnimateRef = (node: HTMLElement | null) => { - if (!node || animatedProjectListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedProjectListsRef.current.add(node); - }; - - const animatedThreadListsRef = useRef(new WeakSet()); - const attachThreadListAutoAnimateRef = (node: HTMLElement | null) => { - if (!node || animatedThreadListsRef.current.has(node)) { - return; - } - autoAnimate(node, SIDEBAR_LIST_ANIMATION_OPTIONS); - animatedThreadListsRef.current.add(node); - }; - - const isManualProjectSorting = sidebarProjectSortOrder === "manual"; - // Build before modifier-dependent context to give the compiler a separate cache used by every row. - const threadJumpLabelByKey = buildThreadJumpLabelMap({ - keybindings, - platform, - terminalOpen: routeTerminalOpen, - threadJumpCommandByKey, - }); - const sidebarShortcutContext = { - terminalFocus: terminalFocused, - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - const shouldShowThreadJumpHintsNow = shouldShowThreadJumpHintsForModifiers( - shortcutModifiers, - keybindings, - { - platform, - context: sidebarShortcutContext, - }, - ); - const showThreadJumpHints = useThreadJumpHintsVisible(shouldShowThreadJumpHintsNow); - const visibleThreadJumpLabelByKey = showThreadJumpHints - ? threadJumpLabelByKey - : EMPTY_THREAD_JUMP_LABELS; - const orderedSidebarThreadKeys = visibleSidebarThreadKeys; - - const onWindowKeyDown = useEffectEvent((event: globalThis.KeyboardEvent) => { - const shortcutContext = { - terminalFocus: isTerminalFocused(), - terminalOpen: routeTerminalOpen, - modelPickerOpen: isModelPickerOpen(), - }; - - if (event.defaultPrevented || event.repeat || isCommandPaletteOpen() || isModelPickerOpen()) { - return; - } - - const command = resolveShortcutCommand(event, keybindings, { - platform, - context: shortcutContext, - }); - const traversalDirection = threadTraversalDirectionFromCommand(command); - if (traversalDirection !== null) { - const targetThreadKey = resolveAdjacentThreadId({ - threadIds: orderedSidebarThreadKeys, - currentThreadId: routeThreadKey, - direction: traversalDirection, - }); - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - return; - } - - const jumpIndex = threadJumpIndexFromCommand(command ?? ""); - if (jumpIndex === null) { - return; - } - - const targetThreadKey = threadJumpThreadKeys[jumpIndex]; - if (!targetThreadKey) { - return; - } - const targetThread = sidebarThreadByKey.get(targetThreadKey); - if (!targetThread) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - navigateToThread(scopeThreadRef(targetThread.environmentId, targetThread.id)); - }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown for thread navigation shortcuts for the sidebar's lifetime - useEffect(() => { - const handleWindowKeyDown = (event: globalThis.KeyboardEvent) => onWindowKeyDown(event); - window.addEventListener("keydown", handleWindowKeyDown); - return () => { - window.removeEventListener("keydown", handleWindowKeyDown); - }; - }, []); - // A window listener whose lifetime is the sidebar's: no element owns outside clicks. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window mousedown listener that clears thread selection on outside clicks for the sidebar's lifetime - useEffect(() => { - window.addEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); - return () => window.removeEventListener("mousedown", clearThreadSelectionOnOutsideMouseDown); - }, []); - - const desktopUpdateButtonDisabled = isDesktopUpdateButtonDisabled(desktopUpdateState); - const desktopUpdateButtonAction = desktopUpdateState - ? resolveDesktopUpdateButtonAction(desktopUpdateState) - : "none"; - const showArm64IntelBuildWarning = - isElectron && shouldShowArm64IntelBuildWarning(desktopUpdateState); - const arm64IntelBuildWarningDescription = - desktopUpdateState && showArm64IntelBuildWarning - ? getArm64IntelBuildWarningDescription(desktopUpdateState) - : null; - const commandPaletteShortcutLabel = isMobile - ? null - : shortcutLabelForCommand(keybindings, "commandPalette.toggle", newThreadShortcutLabelOptions); - const handleDesktopUpdateButtonClick = async () => { - const bridge = window.desktopBridge; - if (!bridge || !desktopUpdateState) return; - if ( - desktopUpdateButtonDisabled || - desktopUpdateButtonAction === "none" || - desktopUpdateActionPending - ) { - return; - } - - setDesktopUpdateActionPending(true); - - if (desktopUpdateButtonAction === "download") { - void bridge - .downloadUpdate() - .then((result) => { - if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); - } - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not download update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not start update download", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setDesktopUpdateActionPending(false)); - return; - } - - if (desktopUpdateButtonAction === "install") { - let confirmed = false; - try { - confirmed = await ensureLocalApi().dialogs.confirm( - getDesktopUpdateInstallConfirmationMessage(desktopUpdateState), - ); - } catch (error) { - setDesktopUpdateActionPending(false); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not confirm update", - description: error instanceof Error ? error.message : "Update confirmation failed.", - }), - ); - return; - } - if (!confirmed) { - setDesktopUpdateActionPending(false); - return; - } - void bridge - .installUpdate() - .then((result) => { - if (!shouldToastDesktopUpdateActionResult(result)) return; - const actionError = getDesktopUpdateActionError(result); - if (!actionError) return; - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: actionError, - }), - ); - }) - .catch((error) => { - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not install update", - description: error instanceof Error ? error.message : "An unexpected error occurred.", - }), - ); - }) - .finally(() => setDesktopUpdateActionPending(false)); - } - }; - - const expandThreadListForProject = (projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (current.has(projectKey)) return current; - const next = new Set(current); - next.add(projectKey); - return next; - }); - }; - - const collapseThreadListForProject = (projectKey: string) => { - setExpandedThreadListsByProject((current) => { - if (!current.has(projectKey)) return current; - const next = new Set(current); - next.delete(projectKey); - return next; - }); - }; - - return ( - - {prewarmedSidebarThreadRefs.map((threadRef) => ( - - ))} - - - - - - ); -} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1bf7a930c1f24..93f3fb342fb9e 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -32,7 +32,11 @@ import { type SidebarListMarker, type SidebarSection, resolveSidebarDropVerb, + groupSidebarThreadsByProject, + resolveSidebarFolderDot, + sidebarFolderExpansionKeys, } from "./Sidebar.logic"; +import { resolveProjectExpanded } from "../uiStateStore"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { EnvironmentId, ProjectId, ProviderInstanceId, RunId, ThreadId } from "@t3tools/contracts"; @@ -1348,3 +1352,97 @@ describe("resolveSidebarRouteReveal", () => { ).toEqual({ pendingKey: hiddenKey, widensScope: false }); }); }); + +describe("sidebarFolderExpansionKeys", () => { + it("honors a collapse the legacy sidebar saved under a member's cwd", () => { + const keys = sidebarFolderExpansionKeys({ + projectKey: "github.com/pingdotgg/t3code", + memberProjects: [{ physicalProjectKey: "local:/code/t3code", workspaceRoot: "/code/t3code" }], + }); + const cwdKey = keys.at(-1)!; + + expect(resolveProjectExpanded({}, keys)).toBe(true); + expect(resolveProjectExpanded({ [cwdKey]: false }, keys)).toBe(false); + expect(resolveProjectExpanded({ [keys[0]!]: true, [cwdKey]: false }, keys)).toBe(true); + }); +}); + +describe("groupSidebarThreadsByProject", () => { + it("puts each thread in its project's folder and keeps empty projects", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const t3code = ProjectId.make("t3code"); + const t3codeRemote = ProjectId.make("t3code-remote"); + const fleet = ProjectId.make("fleet"); + const thread = (id: string, environmentId: EnvironmentId, projectId: ProjectId) => ({ + id, + environmentId, + projectId, + }); + const groups = [ + { + projectKey: "t3code", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: t3code }, + { environmentId: remoteEnvironmentId, projectId: t3codeRemote }, + ], + }, + { + projectKey: "fleet", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: fleet }], + }, + ]; + + const folders = groupSidebarThreadsByProject(groups, [ + thread("a", localEnvironmentId, t3code), + thread("orphan", localEnvironmentId, ProjectId.make("deleted")), + thread("b", remoteEnvironmentId, t3codeRemote), + ]); + + expect( + folders.map((folder) => ({ + projectKey: folder.group.projectKey, + threads: folder.threads.map((entry) => entry.id), + })), + ).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + ]); + }); +}); + +describe("resolveSidebarFolderDot", () => { + it("flags the most urgent hidden thread and stays quiet otherwise", () => { + const idle = { + hasActionableProposedPlan: false, + hasPendingApprovals: false, + hasPendingUserInput: false, + interactionMode: "default" as const, + latestRun: makeLatestRun(), + lastVisitedAt: "2026-03-09T10:06:00.000Z", + runtime: null, + }; + const runtime = { + status: "running" as const, + activeRunId: null, + providerInstanceId: ProviderInstanceId.make("codex"), + providerName: "Codex", + lastError: null, + updatedAt: "2026-03-09T10:00:00.000Z", + }; + const working = { ...idle, runtime }; + const unread = { ...idle, lastVisitedAt: "2026-03-09T10:04:00.000Z" }; + const limited = { + ...idle, + runtime: { ...runtime, status: "failed" as const, lastErrorClass: "usage_limit" as const }, + }; + + expect(resolveSidebarFolderDot([idle])).toBeNull(); + // A waiting thread stays quiet even with an unread completion, like its row. + expect( + resolveSidebarFolderDot([{ ...unread, runtime: { ...runtime, status: "idle" as const } }]), + ).toBeNull(); + expect(resolveSidebarFolderDot([idle, working])).toBe("working"); + expect(resolveSidebarFolderDot([working, unread])).toBe("unread"); + expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index aa4903db93e00..7f7705573673c 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -34,6 +34,7 @@ import { isLatestRunSettled } from "../session-logic"; import { observeIntersection } from "../lib/observeIntersection"; import { deadlineAfter, deadlineStore } from "@t3tools/client-runtime/clock"; import { useDeadlinePassed } from "../hooks/useDeadline"; +import { legacyProjectCwdPreferenceKey } from "../uiStateStore"; export function shouldNavigateAfterThreadPark(input: { readonly threadKey: string; @@ -51,7 +52,6 @@ export function shouldNavigateAfterThreadPark(input: { ); } -const THREAD_SELECTION_SAFE_SELECTOR = "[data-thread-item], [data-thread-selection-safe]"; const THREAD_JUMP_HINT_SHOW_DELAY_MS = 200; export function resolveSidebarRowAccessibility(input: { @@ -67,7 +67,6 @@ export function resolveSidebarRowAccessibility(input: { } // Each prewarm retains growing thread detail and a live subscription, multiplying heap and server load. -const SIDEBAR_THREAD_PREWARM_LIMIT = 3; const SIDEBAR_ROW_SUBSCRIPTION_OVERSCAN_PX = 160; export function useSidebarRowSubscriptionLease(isActive: boolean): { @@ -542,21 +541,6 @@ export async function archiveSelectedThreadEntries< return { archivedThreadKeys, mutationFailure: null, followupFailures }; } -export function buildMultiSelectThreadContextMenuItems(input: { - count: number; - hasRunningThread: boolean; -}): readonly ContextMenuItem<"mark-unread" | "archive" | "delete">[] { - return [ - { id: "mark-unread", label: `Mark unread (${input.count})` }, - { - id: "archive", - label: `Archive (${input.count})`, - disabled: input.hasRunningThread, - }, - { id: "delete", label: `Delete (${input.count})`, destructive: true }, - ]; -} - export function isSidebarSubagentThread(thread: Pick): boolean { return thread.lineage.relationshipToParent === "subagent"; } @@ -722,11 +706,6 @@ function hasUnseenFailure(thread: ThreadStatusInput): boolean { return completedAt > lastVisitedAt; } -export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean { - if (target === null) return true; - return !target.closest(THREAD_SELECTION_SAFE_SELECTOR); -} - // Suppress trailing double-click navigation; keyboard and synthetic clicks use detail 0. export function isTrailingDoubleClick(detail: number): boolean { return detail > 1; @@ -795,13 +774,6 @@ export function orderItemsByPreferredIds(input: { return [...ordered, ...remaining]; } -export function getSidebarThreadIdsToPrewarm( - visibleThreadIds: readonly TThreadId[], - limit = SIDEBAR_THREAD_PREWARM_LIMIT, -): TThreadId[] { - return visibleThreadIds.slice(0, Math.max(0, limit)); -} - export function resolveAdjacentThreadId(input: { threadIds: readonly T[]; currentThreadId: T | null; @@ -829,15 +801,6 @@ export function resolveAdjacentThreadId(input: { return currentIndex < threadIds.length - 1 ? (threadIds[currentIndex + 1] ?? null) : null; } -export function isContextMenuPointerDown(input: { - button: number; - ctrlKey: boolean; - isMac: boolean; -}): boolean { - if (input.button === 2) return true; - return input.isMac && input.button === 0 && input.ctrlKey; -} - export type SidebarThreadStatus = | "approval" | "input" @@ -909,6 +872,92 @@ export function resolveSidebarThreadStatus(thread: SidebarThreadStatusInput): Si return "ready"; } +/** + * The dot on a closed boring-sidebar folder: the most urgent state among its + * hidden threads. Waiting threads stay quiet, like their rows. + */ +export function resolveSidebarFolderDot( + threads: ReadonlyArray, +): "attention" | "failed" | "unread" | "working" | null { + const statuses = new Set(threads.map(resolveSidebarThreadStatus)); + if ( + statuses.has("input") || + statuses.has("approval") || + statuses.has("request") || + statuses.has("limited") + ) { + return "attention"; + } + if (statuses.has("failed")) return "failed"; + // Rows show Done only for ready threads, so the dot follows the same rule. + const hasUnread = threads.some( + (thread) => resolveSidebarThreadStatus(thread) === "ready" && hasUnseenCompletion(thread), + ); + if (hasUnread) return "unread"; + if (statuses.has("working")) return "working"; + return null; +} + +/** + * Keys the old per-project sidebar saved folder open state under, most + * specific first, for `resolveProjectExpanded`. Boring folders read and write + * the same keys, so a legacy user's closed folders stay closed after regrouping. + */ +export function sidebarFolderExpansionKeys(group: { + readonly projectKey: string; + readonly memberProjects: ReadonlyArray<{ + readonly physicalProjectKey: string; + readonly workspaceRoot: string; + }>; +}): string[] { + return [ + group.projectKey, + ...group.memberProjects.map((member) => member.physicalProjectKey), + ...group.memberProjects.map((member) => legacyProjectCwdPreferenceKey(member.workspaceRoot)), + ]; +} + +/** + * Boring sidebar folders. Folders follow `groups` order and include empty + * projects so each one can start a thread. Threads keep their input order. + * A thread whose project is in no group has no folder and is left out. + */ +export function groupSidebarThreadsByProject< + TGroup extends { + readonly memberProjectRefs: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly projectId: string; + }>; + }, + TThread extends Pick, +>( + groups: readonly TGroup[], + threads: readonly TThread[], +): Array<{ group: TGroup; threads: TThread[] }> { + const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); + const folderByProject = new Map(); + for (const folder of folders) { + for (const ref of folder.group.memberProjectRefs) { + folderByProject.set(`${ref.environmentId}:${ref.projectId}`, folder); + } + } + for (const thread of threads) { + folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); + } + return folders; +} + +export type SidebarV2TopStatusKind = + | "approval" + | "done" + | "failed" + | "limited" + | "input" + | "request" + | "waiting" + | "woke" + | "working"; + export function firstValidTimestampMs( ...candidates: ReadonlyArray ): number { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 280e23dad4060..6bda36fc85919 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -110,7 +110,7 @@ import { getSidebarProjectSettingsKey, type SidebarProjectSnapshot, } from "../sidebarProjectGrouping"; -import { useUiStateStore } from "../uiStateStore"; +import { resolveProjectExpanded, useUiStateStore } from "../uiStateStore"; import { getThreadKeysToDeselectAfterDelete, useThreadSelectionStore, @@ -122,7 +122,7 @@ import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { registerSettleAndAdvance } from "../settleAndAdvance"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useBoringSidebarEnabled, useClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -168,6 +168,8 @@ import { import { applySidebarThreadDrop, filterSidebarV2VisibleThreads, + groupSidebarThreadsByProject, + sidebarFolderExpansionKeys, buildBulkTitleRegenerationContextMenuItem, buildBulkUnpinContextMenuItem, deleteSelectedThreadEntries, @@ -246,6 +248,10 @@ import { type SidebarSweepAction, } from "./sidebar/SidebarThreadRow"; import { SidebarDraftBlock } from "./sidebar/SidebarDraftBlock"; +import { + SidebarProjectFolderHeader, + sidebarFolderRowId, +} from "./sidebar/SidebarProjectFolderHeader"; import { SidebarDragBoundary, SidebarSectionHeader, @@ -391,6 +397,8 @@ function useSidebarThreads(input: { readonly threads: readonly EnvironmentThreadShell[]; readonly delegatedUnderParent: ReadonlySet; readonly routeRowThreadKey: string | null; + // Set in boring mode: the project groups to fold active threads into, in order. + readonly folderGroups: readonly SidebarProjectSnapshot[] | null; }) { const { projectScopeKey, @@ -400,8 +408,11 @@ function useSidebarThreads(input: { threads, delegatedUnderParent, routeRowThreadKey, + folderGroups, } = input; - const workingShelfEnabled = useClientSettings((s) => s.sidebarWorkingShelfEnabled); + // Folders already separate the work, so boring mode has no Working shelf. + const workingShelfEnabled = + useClientSettings((s) => s.sidebarWorkingShelfEnabled) && folderGroups === null; const inboxReturnsVersion = useSyncExternalStore( inboxReturns.subscribe, inboxReturns.getSnapshot, @@ -424,6 +435,9 @@ function useSidebarThreads(input: { false, Schema.Boolean, ); + // Shared with the old per-project sidebar, so folders keep their open state. + const projectExpandedById = useUiStateStore((store) => store.projectExpandedById); + const setProjectExpanded = useUiStateStore((store) => store.setProjectExpanded); const [settledTail, setSettledTail] = useState({ scopeKey: projectScopeKey, count: SETTLED_TAIL_INITIAL_COUNT, @@ -478,9 +492,31 @@ function useSidebarThreads(input: { const visibleWorkingThreads = workingShelfExpanded ? workingThreads : routeThreadOnly(workingThreads, routeRowThreadKey); + const projectFolders = + folderGroups === null + ? null + : groupSidebarThreadsByProject(folderGroups, activeThreads).map((folder) => { + const collapsed = !resolveProjectExpanded( + projectExpandedById, + sidebarFolderExpansionKeys(folder.group), + ); + return { + group: folder.group, + collapsed, + // A closed folder still shows the open thread, and hints at the rest. + threads: collapsed + ? routeThreadOnly(folder.threads, routeRowThreadKey) + : folder.threads, + hiddenThreads: collapsed + ? folder.threads.filter((thread) => threadShellKey(thread) !== routeRowThreadKey) + : EMPTY_THREADS, + }; + }); const orderedThreads = [ ...pinnedThreads, - ...activeThreads, + ...(projectFolders === null + ? activeThreads + : projectFolders.flatMap((folder) => folder.threads)), ...visibleWorkingThreads, ...visibleSnoozedThreads, ...renderedSettledThreads, @@ -521,6 +557,11 @@ function useSidebarThreads(input: { toggleSnoozedShelf: () => setSnoozedShelfExpanded((value) => !value), workingShelfExpanded, toggleWorkingShelf: () => setWorkingShelfExpanded((value) => !value), + projectFolders, + toggleProjectFolder: (group: SidebarProjectSnapshot) => { + const keys = sidebarFolderExpansionKeys(group); + setProjectExpanded(keys, !resolveProjectExpanded(projectExpandedById, keys)); + }, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -911,6 +952,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); }, }); + const boringSidebarEnabled = useBoringSidebarEnabled(); const newThreadContext = useHandleNewThread(); // Row callbacks capture only the handler; the rest of the context changes with the route. const { handleNewThread } = newThreadContext; @@ -1145,6 +1187,40 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve openProjectSettings(projectGroup); }, ); + const startThreadInProjectFolder = useStableCallback((group: SidebarProjectSnapshot) => { + if (isMobile) setOpenMobile(false); + void handleNewThread(scopeProjectRef(group.environmentId, group.id)); + }); + const handleProjectFolderContextMenu = useStableCallback( + (group: SidebarProjectSnapshot, position: { x: number; y: number }) => { + const api = readLocalApi(); + if (!api) return; + void (async () => { + const clicked = await settlePromise(() => + api.contextMenu.show( + [ + { id: "new-thread", label: "New thread" }, + { id: "copy-path", label: "Copy path" }, + { id: "project-settings", label: "Project settings", icon: "settings" }, + ], + position, + ), + ); + if (clicked._tag === "Failure") return; + switch (clicked.value) { + case "new-thread": + startThreadInProjectFolder(group); + return; + case "copy-path": + copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); + return; + case "project-settings": + openProjectSettings(group); + return; + } + })(); + }, + ); const { workingShelfEnabled, @@ -1168,6 +1244,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve toggleSnoozedShelf, workingShelfExpanded, toggleWorkingShelf, + projectFolders, + toggleProjectFolder, renderedSettledThreads, visibleSnoozedThreads, visibleWorkingThreads, @@ -1188,6 +1266,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve threads, delegatedUnderParent, routeRowThreadKey, + folderGroups: boringSidebarEnabled + ? scopedProjectGroups.length === 0 + ? projectGroups + : scopedProjectGroups + : null, }); // Ranked across environments as the palette ranks them, so one @@ -1748,6 +1831,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve event.activatorEvent instanceof PointerEvent ? event.activatorEvent.clientY : null, }); }; + const hasProjectFolders = projectFolders !== null && projectFolders.length > 0; const sidebarListItemsFresh = ((): readonly SidebarListItem[] => { const rowsOf = ( list: readonly EnvironmentThreadShell[], @@ -1757,13 +1841,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); + // Boring mode keeps empty project folders, so each can start a thread. if ( + !hasProjectFolders && pinnedThreads.length + activeThreads.length + workingThreads.length + snoozedThreads.length + settledCount === - 0 + 0 ) { return []; } @@ -1813,6 +1899,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve // Only order, section, or draft changes need layout reads and motion. const listMotionLayoutKey = [ sidebarListItemsKey, + projectFolders + ?.map((folder) => `${folder.group.projectKey}:${folder.threads.length}`) + .join("\n") ?? "", visibleDraftSessionCount, routeDraftIdForRows ?? "", ].join("\0"); @@ -2742,7 +2831,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const showJumpHints = useThreadJumpHintsVisible(shouldShowJumpHintsNow); const handleNewThreadClick = (event?: ReactMouseEvent) => { - if (shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length)) { + if ( + boringSidebarEnabled || + shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + ) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -2942,7 +3034,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve newThreadDisabled={projects.length === 0} newThreadShortcutLabel={newThreadShortcutLabel} newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} - showNewThreadInProjectHint={projectGroups.length > 1} + showNewThreadInProjectHint={!boringSidebarEnabled && projectGroups.length > 1} searchInputRef={threadSearchInputRef} searchQuery={threadSearchQuery} onSearchQueryChange={(value) => { @@ -3058,19 +3150,22 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, + // The tree row of the project folder this row sits in. + folderRow?: string, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); const isCard = section === "active" || section === "pinned" || section === "working"; - const rowVariant = isCard ? "card" : "slim"; + const rowVariant = folderRow ? "folder" : isCard ? "card" : "slim"; return ( , + ); + for (const thread of folder.threads) { + items.push( + renderThreadRow( + thread, + "active", + sidebarFolderRowId(folder.group.projectKey), + ), + ); + } + } + continue; + } switch (item.marker) { case "pinned-header": items.push( @@ -3330,6 +3453,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} {!isSearchingThreads && + !hasProjectFolders && visibleDraftSessionCount === 0 && pinnedThreads.length + activeThreads.length + diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 45eb01782c763..682172f1ff308 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -440,7 +440,7 @@ export function ChangeRequestStatusIcon({ return ; } -export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { +function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( {status.tooltipLead} @@ -600,7 +600,7 @@ export function ThreadWorktreeIndicator({ ); } -export function ThreadStatusLabel({ +function ThreadStatusLabel({ status, compact = false, }: { diff --git a/apps/web/src/components/settings/LabsSettingsPanel.tsx b/apps/web/src/components/settings/LabsSettingsPanel.tsx index b06f7b04c4fb8..5f944c867db68 100644 --- a/apps/web/src/components/settings/LabsSettingsPanel.tsx +++ b/apps/web/src/components/settings/LabsSettingsPanel.tsx @@ -4,6 +4,7 @@ import { Link } from "@tanstack/react-router"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; import { Button } from "../ui/button"; +import { Switch } from "../ui/switch"; import { ScopedSwitch } from "./ScopedSwitch"; import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; @@ -15,6 +16,7 @@ export function LabsSettingsPanel() { return ( + {/* Clerk is only mounted when this build has T3 Connect. */} {hasCloudPublicConfig() ? : } @@ -24,6 +26,27 @@ export function LabsSettingsPanel() { ); } +function BoringSidebarRow() { + const settings = useScopedSettings(); + const updateSettings = useUpdateScopedSettings(); + return ( + + updateSettings({ boringSidebarEnabled: Boolean(checked), legacySidebarEnabled: false }) + } + aria-label="Boring sidebar (group by project)" + /> + } + /> + ); +} + /** Signed out of T3 Connect, the row offers to sign in instead of its switch. */ function GuestLinksRowWithSignIn() { const { isSignedIn } = useAuth(); diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 25e297470502f..a4aadac731081 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -425,9 +425,6 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.inAppNotificationsEnabled !== DEFAULT_UNIFIED_SETTINGS.inAppNotificationsEnabled ? ["In-app notifications"] : []), - ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount - ? ["Visible threads"] - : []), ...(settings.sidebarProjectGroupingMode !== DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode ? ["Project Grouping"] : []), @@ -603,7 +600,6 @@ export function useSettingsRestore(onRestored?: () => void) { environmentIdentificationMode: DEFAULT_UNIFIED_SETTINGS.environmentIdentificationMode, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, panelAnimationDurationMs: DEFAULT_UNIFIED_SETTINGS.panelAnimationDurationMs, - sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, sidebarProjectSortOrder: DEFAULT_UNIFIED_SETTINGS.sidebarProjectSortOrder, sidebarWorkingShelfEnabled: DEFAULT_UNIFIED_SETTINGS.sidebarWorkingShelfEnabled, @@ -732,19 +728,6 @@ export function LegacyFeaturesSection() { /> } /> - - updateSettings({ legacySidebarEnabled: Boolean(checked) }) - } - aria-label="Sidebar (legacy)" - /> - } - /> ); } @@ -851,7 +834,7 @@ export function GeneralSettingsPanel() { /> { const streaming = getSettingsSearchTargetScope("response-streaming")!; expect(streaming.scope).toBe("project-defaults"); expect(isSettingsSearchScopeAvailable(streaming.scope, "project")).toBe(true); - for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "legacy-sidebar"]) { + for (const id of ["legacy-plan-mode", "legacy-context-window-indicator", "boring-sidebar"]) { expect(getSettingsSearchTargetScope(id)!.scope).toBeNull(); } }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 460a6ef650b61..25b5b7cc6d24e 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -419,6 +419,12 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/general", searchTerms: ["hide fold running monitoring threads inbox sidebar shelf"], }, + { + id: "boring-sidebar", + title: "Boring sidebar (group by project)", + to: "/settings/labs", + searchTerms: ["beta folders project tree legacy old classic sidebar"], + }, { id: "guest-links", title: "Guest links", @@ -708,12 +714,6 @@ export const SETTINGS_SEARCH_ITEMS = [ to: "/settings/labs", searchTerms: ["composer meter usage tokens circle old"], }, - { - id: "legacy-sidebar", - title: "Sidebar (legacy)", - to: "/settings/labs", - searchTerms: ["project thread tree old flat list"], - }, { id: "keybindings", title: "Keybindings", diff --git a/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx new file mode 100644 index 0000000000000..1ee152533a75b --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarProjectFolderHeader.tsx @@ -0,0 +1,115 @@ +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { ChevronDownIcon, SquarePenIcon } from "lucide-react"; +import { memo } from "react"; + +import { cn } from "~/lib/utils"; +import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; +import { useUiStateStore } from "../../uiStateStore"; +import { resolveSidebarFolderDot, resolveThreadLastVisitedAt } from "../Sidebar.logic"; +import { ProjectFavicon } from "../ProjectFavicon"; +import { Button } from "../ui/button"; + +const FOLDER_DOT_CLASS_NAME = { + attention: "bg-warning", + failed: "bg-error", + unread: "bg-success", + working: "bg-info", +} as const; + +const FOLDER_DOT_LABEL = { + attention: ", needs you", + failed: ", failed", + unread: ", done", + working: ", working", +} as const; + +/** The folder's sidebar tree row; its thread rows name it as their parent. */ +export function sidebarFolderRowId(projectKey: string) { + return `folder:${projectKey}`; +} + +/** + * A project folder in the boring sidebar. It reads like a shelf header: a trailing + * count and chevron, with the count and a status dot only while it is closed, so a + * closed folder still says when a hidden thread needs you. + */ +export const SidebarProjectFolderHeader = memo(function SidebarProjectFolderHeader(props: { + group: SidebarProjectSnapshot; + collapsed: boolean; + hiddenThreads: readonly EnvironmentThreadShell[]; + onToggle: (group: SidebarProjectSnapshot) => void; + onNewThread: (group: SidebarProjectSnapshot) => void; + onContextMenu: (group: SidebarProjectSnapshot, position: { x: number; y: number }) => void; +}) { + const { group, collapsed, hiddenThreads } = props; + const rowId = sidebarFolderRowId(group.projectKey); + const localLastVisitedAtById = useUiStateStore((state) => state.threadLastVisitedAtById); + const dot = collapsed + ? resolveSidebarFolderDot( + hiddenThreads.map((thread) => ({ + ...thread, + lastVisitedAt: resolveThreadLastVisitedAt( + thread.lastVisitedAt, + localLastVisitedAtById[ + scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)) + ], + ), + })), + ) + : null; + return ( +
  • + + {/* Sits over the count, which hides on hover, and left of the chevron. */} + + + +
  • + ); +}); diff --git a/apps/web/src/components/sidebar/SidebarThreadRow.tsx b/apps/web/src/components/sidebar/SidebarThreadRow.tsx index dff97ef21057d..eac7c8591fa1c 100644 --- a/apps/web/src/components/sidebar/SidebarThreadRow.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadRow.tsx @@ -515,9 +515,26 @@ const dropVerbBadge: Record = { ), }; +const TOP_STATUS_ICONS = { + working: CircleDashedIcon, + waiting: CircleDotDashedIcon, + input: MessageCircleQuestionIcon, + request: UserRoundIcon, + approval: ShieldQuestionIcon, + failed: CircleAlertIcon, + done: CircleCheckIcon, + woke: AlarmClockIcon, +} as const; + +function TopStatusIcon(props: { icon: keyof typeof TOP_STATUS_ICONS; className: string }) { + const Icon = TOP_STATUS_ICONS[props.icon]; + return ; +} + type SidebarThreadRowProps = { thread: SidebarThreadSummary; - variant: "card" | "slim"; + // "folder" is a one-line row inside a boring sidebar project folder. + variant: "card" | "slim" | "folder"; navigationParent?: string | undefined; variantAction: SidebarSweepAction; settlementSupported: boolean; @@ -997,7 +1014,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP className={cn( "min-w-0 flex-1 text-start text-sm transition-opacity motion-reduce:transition-none", shouldRecede ? "font-normal" : "font-medium", - variant === "card" + variant !== "slim" ? cn( "truncate", shouldRecede @@ -1104,7 +1121,8 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP ) ) : null; - if (variant === "slim") { + if (variant !== "card") { + const inFolder = variant === "folder"; return (
  • {accessibleTitle} - - {props.project ? : null} - + {inFolder ? null : ( + + {props.project ? ( + + ) : null} + + )} {draftIndicator} {title} {pinIndicator} @@ -1176,7 +1203,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP {props.snoozeWakeLabelText} - ) : isWoke ? ( + ) : (inFolder ? isWokeStatus : isWoke) ? ( Dismiss Woke notification + ) : inFolder && topStatus ? ( + // Folder rows have no status line, so the status takes the time slot. + + + {topStatus.label} + ) : ( - {topStatus.icon === "working" ? ( - - ) : topStatus.icon === "waiting" ? ( - - ) : topStatus.icon === "input" ? ( - - ) : topStatus.icon === "request" ? ( - - ) : topStatus.icon === "approval" ? ( - - ) : topStatus.icon === "failed" ? ( - - ) : topStatus.icon === "done" ? ( - - ) : null} + {/* Not a live region: a collapsed sidebar is inert, so BackgroundThreadAnnouncer and the open thread's timeline speak status changes instead. */} diff --git a/apps/web/src/components/ui/sidebar.tsx b/apps/web/src/components/ui/sidebar.tsx index b4246f2cfabf0..6060a867fed7a 100644 --- a/apps/web/src/components/ui/sidebar.tsx +++ b/apps/web/src/components/ui/sidebar.tsx @@ -97,7 +97,7 @@ function useSidebarVisibility() { return isMobile ? openMobile : open; } -// Same breakpoint as useIsMobile, also read when a shortcut fires. +// The `max-md` breakpoint, also read when a shortcut fires. const SIDEBAR_MOBILE_QUERY = "(max-width: 767px)"; function isMobileViewport(): boolean { diff --git a/apps/web/src/connection/desktopLocal.ts b/apps/web/src/connection/desktopLocal.ts index 746344759a76e..eb46a09ab3ac6 100644 --- a/apps/web/src/connection/desktopLocal.ts +++ b/apps/web/src/connection/desktopLocal.ts @@ -26,10 +26,6 @@ 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/hooks/useMediaQuery.ts b/apps/web/src/hooks/useMediaQuery.ts index ec6ee4fa51160..f3df1b3a4b1de 100644 --- a/apps/web/src/hooks/useMediaQuery.ts +++ b/apps/web/src/hooks/useMediaQuery.ts @@ -77,7 +77,3 @@ export function useMediaQuery(query: BreakpointQuery | MediaQueryInput | (string return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); } - -export function useIsMobile(): boolean { - return useMediaQuery("max-md"); -} diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 70f8ac321f54f..87733457a417b 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -415,11 +415,11 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod }); } -/** Wait for hydration: swapping sidebar implementations after mount remounts the whole tree. */ -export function useLegacySidebarEnabled(): boolean { - const settingsHydrated = useClientSettingsHydrated(); - const legacySidebarEnabled = useClientSettings((settings) => settings.legacySidebarEnabled); - return settingsHydrated && legacySidebarEnabled; +/** Legacy sidebar opt-ins still count, so those users land in the boring sidebar. */ +export function useBoringSidebarEnabled(): boolean { + return useClientSettings( + (settings) => settings.boringSidebarEnabled || settings.legacySidebarEnabled, + ); } export async function prepareModelPreferencesPatch( diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index e832ca295284e..f0f80dfed8830 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -117,8 +117,10 @@ export function installChatRouteCommands(router: AnyRouter): () => void { void scratch.startScratchThread(environmentId); }, "chat.new": (event) => { - // Pre-hydration settings are the defaults, where the legacy sidebar is off. - if (!getClientSettings().legacySidebarEnabled && readProjectGroupCount() > 1) { + // The boring sidebar's folders already show the context, so it starts in + // the current project like the legacy sidebar did. + const { boringSidebarEnabled, legacySidebarEnabled } = getClientSettings(); + if (!boringSidebarEnabled && !legacySidebarEnabled && readProjectGroupCount() > 1) { claim(event); openCommandPalette({ open: "new-thread-in" }); return; diff --git a/apps/web/src/state/use-orchestration-command.ts b/apps/web/src/state/use-orchestration-command.ts index 1c801abc3c6b7..98f7efeecbfe6 100644 --- a/apps/web/src/state/use-orchestration-command.ts +++ b/apps/web/src/state/use-orchestration-command.ts @@ -14,8 +14,7 @@ import { AsyncResult } from "effect/reactivity"; import { readEnvironmentScope } from "./session"; import { useAtomCommand } from "./use-atom-command"; -export const ORCHESTRATION_OPERATE_DENIED_MESSAGE = - "This connection cannot change threads or projects."; +const ORCHESTRATION_OPERATE_DENIED_MESSAGE = "This connection cannot change threads or projects."; export function useOrchestrationCommand( command: AtomCommand, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index ebab04125a3bf..b5373749a4956 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -139,6 +139,15 @@ choice. While this is on, the active list is ordered by when each thread last came back to you, so you cannot drag or move threads within it. Your saved order returns when you turn it off. +### Group threads by project (beta) + +Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active +threads in a folder for each project. Click a folder to open or close it. A closed folder shows how +many threads it holds, and a dot when one of them needs you. Use the pen button on a folder, or +right-click it, to start a thread in that project. Pinned threads stay above the folders, and +Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section +is off. Each device keeps its own choice. + ## 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/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index 6866c02b08cfd..a23fd4a79e0bc 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -78,7 +78,7 @@ export function derivePhysicalProjectKey( return derivePhysicalProjectKeyFromPath(project.environmentId, project.workspaceRoot); } -export function deriveProjectGroupingOverrideKey( +function deriveProjectGroupingOverrideKey( project: Pick, ): string { return derivePhysicalProjectKey(project); diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index a21013004c6cb..8fc9a46116e78 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -72,16 +72,6 @@ export const SidebarProjectGroupingMode = Schema.Literals([ ]); export type SidebarProjectGroupingMode = typeof SidebarProjectGroupingMode.Type; const DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE: SidebarProjectGroupingMode = "repository"; -export const MIN_SIDEBAR_THREAD_PREVIEW_COUNT = 1; -export const MAX_SIDEBAR_THREAD_PREVIEW_COUNT = 15; -export const SidebarThreadPreviewCount = Schema.Int.check( - Schema.isBetween({ - minimum: MIN_SIDEBAR_THREAD_PREVIEW_COUNT, - maximum: MAX_SIDEBAR_THREAD_PREVIEW_COUNT, - }), -); -export type SidebarThreadPreviewCount = typeof SidebarThreadPreviewCount.Type; -const DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT: SidebarThreadPreviewCount = 6; export const MIN_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 1; export const MAX_SIDEBAR_AUTO_SETTLE_AFTER_DAYS = 90; export const SidebarAutoSettleAfterDays = Schema.Number.check( @@ -479,6 +469,9 @@ export const ClientSettingsSchema = Schema.Struct({ ), proactivePanelsEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), showSkillsInSlashMenu: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), + boringSidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + // Opt-in for the removed legacy sidebar. Still read so those users land in + // the boring sidebar; the boring sidebar toggle clears it. legacySidebarEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), sidebarWorkingShelfEnabled: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), sidebarProjectGroupingMode: SidebarProjectGroupingMode.pipe( @@ -494,9 +487,6 @@ export const ClientSettingsSchema = Schema.Struct({ sidebarThreadSortOrder: SidebarThreadSortOrder.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_SORT_ORDER)), ), - sidebarThreadPreviewCount: SidebarThreadPreviewCount.pipe( - Schema.withDecodingDefault(Effect.succeed(DEFAULT_SIDEBAR_THREAD_PREVIEW_COUNT)), - ), timestampFormat: TimestampFormat.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_TIMESTAMP_FORMAT)), ), @@ -1712,6 +1702,7 @@ export const ClientSettingsPatch = Schema.Struct({ followUpBehavior: Schema.optionalKey(Schema.Literals(["queue", "steer"])), proactivePanelsEnabled: Schema.optionalKey(Schema.Boolean), showSkillsInSlashMenu: Schema.optionalKey(Schema.Boolean), + boringSidebarEnabled: Schema.optionalKey(Schema.Boolean), legacySidebarEnabled: Schema.optionalKey(Schema.Boolean), sidebarWorkingShelfEnabled: Schema.optionalKey(Schema.Boolean), sidebarProjectGroupingMode: Schema.optionalKey(SidebarProjectGroupingMode), @@ -1720,7 +1711,6 @@ export const ClientSettingsPatch = Schema.Struct({ ), sidebarProjectSortOrder: Schema.optionalKey(SidebarProjectSortOrder), sidebarThreadSortOrder: Schema.optionalKey(SidebarThreadSortOrder), - sidebarThreadPreviewCount: Schema.optionalKey(SidebarThreadPreviewCount), timestampFormat: Schema.optionalKey(TimestampFormat), snapShotEnabled: Schema.optionalKey(Schema.Boolean), snapShotIncludeAccessibility: Schema.optionalKey(Schema.Boolean), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0adcb65c50062..5889ab5607c79 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1033,9 +1033,6 @@ importers: '@fontsource-variable/inter': specifier: ^5.3.0 version: 5.3.0 - '@formkit/auto-animate': - specifier: ^0.9.0 - version: 0.9.0 '@legendapp/list': specifier: 3.3.5 version: 3.3.5(patch_hash=6cbdacd8c380d9a5afb9f73d4198dc2cc44be6ef28a955b1183c781469307896)(react-dom@19.3.0(react@19.3.0))(react-native@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(@react-native/metro-config@0.88.0(@babel/core@7.29.7(supports-color@8.1.1))(bufferutil@4.1.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(@types/react@19.3.0)(bufferutil@4.1.0)(react@19.3.0)(supports-color@8.1.1)(utf-8-validate@6.0.6))(react@19.3.0) @@ -4311,9 +4308,6 @@ packages: '@fontsource-variable/inter@5.3.0': resolution: {integrity: sha512-OupL48va4JNofb97w6NYeF9S7W/kHNKM0Er8Dem5nqi4jeOLrVJDoE8tZEpnMJmtkvNbB1EIPPwHcdkF6b1oUA==} - '@formkit/auto-animate@0.9.0': - resolution: {integrity: sha512-VhP4zEAacXS3dfTpJpJ88QdLqMTcabMg0jwpOSxZ/VzfQVfl3GkZSCZThhGC5uhq/TxPHPzW0dzr4H9Bb1OgKA==} - '@hono/node-server@1.19.17': resolution: {integrity: sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==} engines: {node: '>=18.14.1'} @@ -14427,8 +14421,6 @@ snapshots: '@fontsource-variable/inter@5.3.0': {} - '@formkit/auto-animate@0.9.0': {} - '@hono/node-server@1.19.17(hono@4.13.7)': dependencies: hono: 4.13.7 From 9ebca5f71cbcf050e94732f0a1da041073223c84 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 00:13:53 -0700 Subject: [PATCH 195/317] fix(relay): tunnel limit counts abandoned reprovisions and frees failed revokes (#26320) Co-authored-by: Claude Opus 5.5 --- .../migration.sql | 12 + .../snapshot.json | 1571 +++++++++++++++++ infra/relay/package.json | 1 + .../ManagedEndpointAllocations.test.ts | 27 + .../ManagedEndpointAllocations.ts | 9 +- .../ManagedEndpointProvider.test.ts | 46 + .../environments/ManagedEndpointProvider.ts | 26 + .../environments/ManagedTunnelLimits.test.ts | 204 ++- .../src/environments/ManagedTunnelLimits.ts | 96 +- pnpm-lock.yaml | 6 +- 10 files changed, 1931 insertions(+), 67 deletions(-) create mode 100644 infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql create mode 100644 infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json diff --git a/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql new file mode 100644 index 0000000000000..4c822889bd98e --- /dev/null +++ b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/migration.sql @@ -0,0 +1,12 @@ +-- Teardown claims used to bump updated_at past the link's revoke or publish-only write, so a +-- failed deprovision now reads as an unfinished relink. Give those rows the updated_at the +-- current write rule would have left. Touches only withdrawn links' live tunnels; idempotent. +UPDATE "relay_managed_endpoint_allocations" AS a +SET "updated_at" = l."updated_at" +FROM "relay_environment_links" AS l +WHERE l."user_id" = a."user_id" + AND l."environment_id" = a."environment_id" + AND a."tunnel_id" IS NOT NULL + AND a."tunnel_released_at" IS NULL + AND (l."revoked_at" IS NOT NULL OR l."managed_tunnels_enabled" = false) + AND l."updated_at" < a."updated_at"; diff --git a/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json new file mode 100644 index 0000000000000..e02f3a0c6f2e7 --- /dev/null +++ b/infra/relay/migrations/postgres/20261028062605_backfill_withdrawn_tunnel_allocations/snapshot.json @@ -0,0 +1,1571 @@ +{ + "id": "4413755a-63f1-4f93-bdc9-d1357283c23e", + "prevIds": ["7d8c1702-6f47-4453-a8ee-66ce5e3bfb41"], + "version": "8", + "dialect": "postgres", + "ddl": [ + { + "isRlsEnabled": false, + "name": "relay_agent_activity_rows", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_delivery_attempts", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_dpop_proofs", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_credentials", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_environment_links", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_live_activities", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_endpoint_allocations", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_managed_tunnel_limits", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_mobile_devices", + "entityType": "tables", + "schema": "public" + }, + { + "isRlsEnabled": false, + "name": "relay_upstream_api_pauses", + "entityType": "tables", + "schema": "public" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "state_json", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "type": "varchar(36)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(512)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thread_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "kind", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "source_job_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "token_suffix", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_status", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_reason", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "apns_id", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "transport_error", + "entityType": "columns", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "type": "varchar(128)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "thumbprint", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "jti", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "iat", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "expires_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "credential_hash", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'T3 Environment'", + "generated": null, + "identity": null, + "name": "environment_label", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_http_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_ws_base_url", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(32)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "endpoint_provider_kind", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "notifications_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "true", + "generated": null, + "identity": null, + "name": "live_activities_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "managed_tunnels_enabled", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "boolean", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "false", + "generated": null, + "identity": null, + "name": "hold_webhooks_while_offline", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_by_device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "revoked_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_environment_links" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "activity_push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_start_queued_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "remote_started_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ended_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_aggregate_json", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "last_live_activity_delivery_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_live_activities" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "environment_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "hostname", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_name", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "dns_record_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ready_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recovery_enabled_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "recovery_environment_public_key", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "tunnel_released_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "origin", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "0", + "generated": null, + "identity": null, + "name": "generation", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "type": "varchar(191)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "max_tunnels", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_managed_tunnel_limits" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "user_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "device_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": "'iOS device'", + "generated": null, + "identity": null, + "name": "label", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "platform", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "ios_major_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "integer", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "android_api_level", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "app_version", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(255)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "bundle_id", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(16)", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "aps_environment", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "text", + "typeSchema": null, + "notNull": false, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "push_to_start_token", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "jsonb", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "preferences_json", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "created_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "updated_at", + "entityType": "columns", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "type": "varchar(64)", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "api", + "entityType": "columns", + "schema": "public", + "table": "relay_upstream_api_pauses" + }, + { + "type": "bigint", + "typeSchema": null, + "notNull": true, + "dimensions": 0, + "default": null, + "generated": null, + "identity": null, + "name": "paused_until_ms", + "entityType": "columns", + "schema": "public", + "table": "relay_upstream_api_pauses" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "created_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_created", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "source_job_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_delivery_attempts_source_job", + "entityType": "indexes", + "schema": "public", + "table": "relay_delivery_attempts" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "expires_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_dpop_proofs_expires_at", + "entityType": "indexes", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "credential_hash", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_hash", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "environment_public_key", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_credentials_environment_key", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_credentials" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "environment_id", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + }, + { + "value": "revoked_at", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": false, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_environment_links_environment", + "entityType": "indexes", + "schema": "public", + "table": "relay_environment_links" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "activity_push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_live_activities_activity_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_live_activities" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "hostname", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_hostname", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "tunnel_name", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_managed_endpoint_allocations_tunnel_name", + "entityType": "indexes", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "nameExplicit": true, + "columns": [ + { + "value": "push_to_start_token", + "isExpression": false, + "asc": true, + "nullsFirst": false, + "opclass": null + } + ], + "isUnique": true, + "where": null, + "with": "", + "method": "btree", + "concurrently": false, + "name": "idx_relay_mobile_devices_push_to_start_token", + "entityType": "indexes", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["environment_id", "environment_public_key", "thread_id"], + "nameExplicit": false, + "name": "relay_agent_activity_rows_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_agent_activity_rows" + }, + { + "columns": ["thumbprint", "jti"], + "nameExplicit": false, + "name": "relay_dpop_proofs_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_dpop_proofs" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_environment_links_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_environment_links" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_live_activities_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_live_activities" + }, + { + "columns": ["user_id", "environment_id"], + "nameExplicit": false, + "name": "relay_managed_endpoint_allocations_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_managed_endpoint_allocations" + }, + { + "columns": ["user_id", "device_id"], + "nameExplicit": false, + "name": "relay_mobile_devices_pkey", + "entityType": "pks", + "schema": "public", + "table": "relay_mobile_devices" + }, + { + "columns": ["id"], + "nameExplicit": false, + "name": "relay_delivery_attempts_pkey", + "schema": "public", + "table": "relay_delivery_attempts", + "entityType": "pks" + }, + { + "columns": ["credential_id"], + "nameExplicit": false, + "name": "relay_environment_credentials_pkey", + "schema": "public", + "table": "relay_environment_credentials", + "entityType": "pks" + }, + { + "columns": ["user_id"], + "nameExplicit": false, + "name": "relay_managed_tunnel_limits_pkey", + "schema": "public", + "table": "relay_managed_tunnel_limits", + "entityType": "pks" + }, + { + "columns": ["api"], + "nameExplicit": false, + "name": "relay_upstream_api_pauses_pkey", + "schema": "public", + "table": "relay_upstream_api_pauses", + "entityType": "pks" + } + ], + "renames": [] +} diff --git a/infra/relay/package.json b/infra/relay/package.json index c75e4b70648d2..197c37819981e 100644 --- a/infra/relay/package.json +++ b/infra/relay/package.json @@ -33,6 +33,7 @@ "@cloudflare/workers-types": "^4.20260601.1", "@effect/platform-node": "catalog:", "@effect/vitest": "catalog:", + "@electric-sql/pglite": "0.3.15", "@types/node": "catalog:", "drizzle-kit": "1.0.0-rc.5-ab785fc", "typescript": "catalog:", diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts index 931e8a2b8893d..f0cd86bef9669 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.test.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.test.ts @@ -74,4 +74,31 @@ describe("ManagedEndpointAllocations", () => { ]); }).pipe(Effect.provide(layerWithDb(fakeDb))); }); + + it.effect("teardown claims leave updatedAt to provisioning writes", () => { + // The tunnel limit compares link and allocation updatedAt; a claim that bumps it makes a + // failed revoke cleanup look like a reprovision and keeps holding the user's slot. + const writes: Array> = []; + const fakeDb = { + update: () => ({ + set: (values: Record) => { + writes.push(values); + return { + where: () => ({ returning: () => Effect.succeed([{ generation: 2 }]) }), + }; + }, + }), + } as unknown as RelayDb.RelayDb["Service"]; + + return Effect.gen(function* () { + const allocations = yield* ManagedEndpointAllocations.ManagedEndpointAllocations; + const key = { userId: "user-1", environmentId: "environment-1", generation: 1 }; + yield* allocations.claimDeprovision(key); + yield* allocations.claimRelease({ ...key, tunnelId: "tunnel-1" }); + yield* allocations.claimRelease({ ...key, tunnelId: "tunnel-1", markReleased: true }); + + expect(writes.map((values) => "updatedAt" in values)).toEqual([false, false, true]); + expect(writes[2]?.["updatedAt"]).toBe(writes[2]?.["tunnelReleasedAt"]); + }).pipe(Effect.provide(layerWithDb(fakeDb))); + }); }); diff --git a/infra/relay/src/environments/ManagedEndpointAllocations.ts b/infra/relay/src/environments/ManagedEndpointAllocations.ts index d432f45c61763..918c410e35176 100644 --- a/infra/relay/src/environments/ManagedEndpointAllocations.ts +++ b/infra/relay/src/environments/ManagedEndpointAllocations.ts @@ -512,9 +512,8 @@ const make = Effect.gen(function* () { const claimed = yield* db .update(relayManagedEndpointAllocations) .set({ - updatedAt: now, generation: sql`${relayManagedEndpointAllocations.generation} + 1`, - ...(input.markReleased === true ? { tunnelReleasedAt: now } : {}), + ...(input.markReleased === true ? { tunnelReleasedAt: now, updatedAt: now } : {}), }) .where( and( @@ -585,10 +584,8 @@ const make = Effect.gen(function* () { ) { const claimed = yield* db .update(relayManagedEndpointAllocations) - .set({ - updatedAt: DateTime.formatIso(yield* DateTime.now), - generation: sql`${relayManagedEndpointAllocations.generation} + 1`, - }) + // Claims move only the generation: the tunnel limit reads updatedAt as the last provisioning write. + .set({ generation: sql`${relayManagedEndpointAllocations.generation} + 1` }) .where( and( whereAllocation(input), diff --git a/infra/relay/src/environments/ManagedEndpointProvider.test.ts b/infra/relay/src/environments/ManagedEndpointProvider.test.ts index 36ea69d5a2694..01230171be9ba 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.test.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.test.ts @@ -1406,6 +1406,52 @@ describe("ManagedEndpointProvider", () => { }).pipe(Effect.provide(layer)); }); + it.effect("marks the tunnel released when removing the allocation fails after deletion", () => { + const allocationCalls: AllocationCall[] = []; + const allocations = makeAllocations(allocationCalls); + const layer = layerProvider(makePersistentTunnelClient(), makeDnsClient(), { + ...allocations, + removeClaimed: (input) => + Effect.suspend(() => { + allocationCalls.push({ operation: "removeClaimed", input }); + return Effect.fail( + new ManagedEndpointAllocations.ManagedEndpointAllocationPersistenceError({ + operation: "remove-claimed", + stage: "database-request", + userId: input.userId, + environmentId: input.environmentId, + }), + ); + }), + }); + + return Effect.gen(function* () { + const provider = yield* ManagedEndpointProvider.ManagedEndpointProvider; + const key = { userId: "user_ABC", environmentId: "env_ABC" } as const; + yield* provider.provision({ + ...key, + origin: { localHttpHost: "127.0.0.1", localHttpPort: 3773 }, + }); + const result = yield* Effect.result(provider.deprovision(key)); + + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure).toMatchObject({ stage: "remove-allocation" }); + } + // The tunnel is gone; without the mark the tunnel limit would keep counting it. + const claim = allocationCalls.find((call) => call.operation === "claimDeprovision"); + if (claim === undefined) return expect.unreachable("deprovision never claimed the row"); + expect(allocationCalls.at(-1)).toMatchObject({ + operation: "claimRelease", + input: { + tunnelId: "tunnel-id", + generation: (claim.input as { generation: number }).generation + 1, + markReleased: true, + }, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("treats already deleted remote resources as successfully deprovisioned", () => { const allocationCalls: AllocationCall[] = []; const notFound = { _tag: "NotFound" } as const; diff --git a/infra/relay/src/environments/ManagedEndpointProvider.ts b/infra/relay/src/environments/ManagedEndpointProvider.ts index 5524b5117faaf..9a76b9c09981a 100644 --- a/infra/relay/src/environments/ManagedEndpointProvider.ts +++ b/infra/relay/src/environments/ManagedEndpointProvider.ts @@ -882,6 +882,7 @@ const make = Effect.gen(function* () { return false; } const tunnelId = allocation.tunnelId; + let tunnelDeleted = false; const deprovision = Effect.gen(function* () { const dnsRecordId = allocation.dnsRecordId; if (dnsRecordId !== null) { @@ -909,6 +910,7 @@ const make = Effect.gen(function* () { }), ), ); + tunnelDeleted = true; } return yield* allocations .removeClaimed({ @@ -956,6 +958,30 @@ const make = Effect.gen(function* () { }), ), }), + // The rollback keeps the deleted tunnel's id; mark it released so the tunnel limit stops + // counting it. onError also covers an interrupted unlink, which rolls back the same way. + Effect.onError(() => + tunnelDeleted + ? allocations + .claimRelease({ + userId: input.userId, + environmentId: input.environmentId, + tunnelId, + generation: claimedGeneration, + markReleased: true, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Failed to mark a deleted managed tunnel released", { + userId: input.userId, + environmentId: input.environmentId, + tunnelId, + error: error.message, + }), + ), + ) + : Effect.void, + ), ); return Option.getOrElse(removed, () => false); }), diff --git a/infra/relay/src/environments/ManagedTunnelLimits.test.ts b/infra/relay/src/environments/ManagedTunnelLimits.test.ts index d45dfc0fde8fd..5837ae3f0eb52 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.test.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Semaphore from "effect/Semaphore"; +import { PGlite } from "@electric-sql/pglite"; +import { drizzle } from "drizzle-orm/pglite"; +import { ne } from "drizzle-orm"; import { PgDialect } from "drizzle-orm/pg-core"; import type { RelayT3ChatPlan } from "@t3tools/contracts/relay"; @@ -11,6 +16,8 @@ import { relayManagedEndpointAllocations } from "../persistence/schema.ts"; import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; import * as ManagedTunnelLimits from "./ManagedTunnelLimits.ts"; +const BACKFILL_MIGRATION = "20261028062605_backfill_withdrawn_tunnel_allocations"; + class FakeTransaction extends Context.Service()( "t3code-relay/environments/ManagedTunnelLimits.test/FakeTransaction", ) {} @@ -19,7 +26,6 @@ interface FakeDbInput { readonly otherSlots: () => number; readonly ownSlot?: boolean; readonly override?: number; - readonly countConditions?: Array; } // Models Postgres just enough: the advisory lock only works inside a transaction and is held until it ends. @@ -59,7 +65,6 @@ function makeFakeDb(input: FakeDbInput) { ), }; } - input.countConditions?.push(condition); const countsOthers = dialect .sqlToQuery(condition as never) .sql.includes('"relay_managed_endpoint_allocations"."environment_id" <>'); @@ -154,24 +159,191 @@ describe("ManagedTunnelLimits", () => { ); }); - it.effect("pending reservations stop counting once released or withdrawn", () => { - const countConditions: Array = []; + it.effect("counts each allocation by its tunnel and link state", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pg = yield* Effect.acquireRelease( + Effect.promise(() => PGlite.create()), + (client) => Effect.promise(() => client.close()), + ); + const migrationsDir = new URL("../../migrations/postgres/", import.meta.url).pathname; + const migrations = (yield* fs.readDirectory(migrationsDir)).toSorted(); + const applyMigrations = (names: ReadonlyArray) => + Effect.forEach(names, (name) => + fs + .readFileString(`${migrationsDir}${name}/migration.sql`) + .pipe(Effect.flatMap((migration) => Effect.promise(() => pg.exec(migration)))), + ); + const backfill = migrations.indexOf(BACKFILL_MIGRATION); + expect(backfill).toBeGreaterThan(-1); + yield* applyMigrations(migrations.slice(0, backfill)); - return Effect.gen(function* () { - yield* reserve({ activeTunnels: 0, plan: "free", countConditions }); + const at = (minutes: number) => `2026-10-27T12:${String(minutes).padStart(2, "0")}:00.000Z`; + const cases: ReadonlyArray<{ + readonly environmentId: string; + readonly counted: boolean; + readonly allocation: { + readonly tunnelId?: string; + readonly releasedAt?: number; + readonly updatedAt: number; + }; + readonly link?: { + readonly revoked?: boolean; + readonly publishOnly?: boolean; + readonly updatedAt: number; + }; + }> = [ + { environmentId: "pending", counted: true, allocation: { updatedAt: 50 } }, + { environmentId: "abandoned-reservation", counted: false, allocation: { updatedAt: 10 } }, + { + environmentId: "live", + counted: true, + allocation: { tunnelId: "t", updatedAt: 10 }, + link: { updatedAt: 12 }, + }, + { + environmentId: "released", + counted: false, + allocation: { tunnelId: "t", releasedAt: 20, updatedAt: 20 }, + }, + { + environmentId: "re-reserved-after-release", + counted: true, + allocation: { tunnelId: "t", releasedAt: 20, updatedAt: 50 }, + }, + { + environmentId: "revoked", + counted: false, + allocation: { tunnelId: "t", updatedAt: 10 }, + link: { revoked: true, updatedAt: 20 }, + }, + { + environmentId: "revoked-pending", + counted: false, + allocation: { updatedAt: 50 }, + link: { revoked: true, updatedAt: 55 }, + }, + { + environmentId: "publish-only", + counted: false, + allocation: { tunnelId: "t", updatedAt: 10 }, + link: { publishOnly: true, updatedAt: 20 }, + }, + // Reprovisioned after a revoke, but the Worker died before rewriting the link. + { + environmentId: "reprovision-before-link", + counted: true, + allocation: { tunnelId: "t", updatedAt: 20 }, + link: { revoked: true, updatedAt: 10 }, + }, + { + environmentId: "reserved-before-link-rewrite", + counted: true, + allocation: { updatedAt: 50 }, + link: { revoked: true, updatedAt: 48 }, + }, + // Tunnel deleted but the row survived; deprovision marked it released, then a relink stalled. + { + environmentId: "released-after-failed-removal-then-reserved", + counted: false, + allocation: { tunnelId: "t", releasedAt: 20, updatedAt: 30 }, + link: { revoked: true, updatedAt: 15 }, + }, + { + environmentId: "withdrawn-at-provisioning-write", + counted: false, + allocation: { tunnelId: "t", updatedAt: 20 }, + link: { revoked: true, updatedAt: 20 }, + }, + { environmentId: "self", counted: false, allocation: { tunnelId: "t", updatedAt: 10 } }, + ]; + // Written before the deploy: claimDeprovision bumped updatedAt past the revoke, then cleanup failed. + const legacyCases: typeof cases = [ + { + environmentId: "legacy-failed-deprovision", + counted: false, + allocation: { tunnelId: "t", updatedAt: 30 }, + link: { revoked: true, updatedAt: 20 }, + }, + { + environmentId: "legacy-failed-publish-only-deprovision", + counted: false, + allocation: { tunnelId: "t", updatedAt: 30 }, + link: { publishOnly: true, updatedAt: 20 }, + }, + { + environmentId: "legacy-live", + counted: true, + allocation: { tunnelId: "t", updatedAt: 30 }, + link: { updatedAt: 20 }, + }, + ]; + const insert = (entries: typeof cases) => + Effect.forEach(entries, (entry) => + Effect.promise(async () => { + await pg.query( + `insert into relay_managed_endpoint_allocations + (user_id, environment_id, hostname, tunnel_id, tunnel_name, tunnel_released_at, created_at, updated_at) + values ('user-1', $1::text, $1::text, $2, $1::text, $3, $4, $4)`, + [ + entry.environmentId, + entry.allocation.tunnelId ?? null, + entry.allocation.releasedAt === undefined ? null : at(entry.allocation.releasedAt), + at(entry.allocation.updatedAt), + ], + ); + const link = entry.link; + if (link !== undefined) { + await pg.query( + `insert into relay_environment_links + (user_id, environment_id, environment_public_key, endpoint_http_base_url, + endpoint_ws_base_url, endpoint_provider_kind, revoked_at, managed_tunnels_enabled, + created_at, updated_at) + values ('user-1', $1, 'key', 'https://e', 'wss://e', 'cloudflare_tunnel', $2, $3, $4, $4)`, + [ + entry.environmentId, + link.revoked === true ? at(link.updatedAt) : null, + link.publishOnly !== true, + at(link.updatedAt), + ], + ); + } + }), + ); + yield* insert(legacyCases); + yield* applyMigrations(migrations.slice(backfill)); + yield* insert(cases); + yield* Effect.promise(() => + pg.query( + `insert into relay_managed_endpoint_allocations + (user_id, environment_id, hostname, tunnel_id, tunnel_name, created_at, updated_at) + values ('user-2', 'other-user', 'other-user', 't', 'other-user', $1, $1)`, + [at(10)], + ), + ); - expect(countConditions).toHaveLength(1); - const query = new PgDialect().sqlToQuery(countConditions[0] as never); - // claimRelease writes updatedAt = tunnelReleasedAt; a later reserve moves updatedAt past it. - expect(query.sql).toContain( - '"relay_managed_endpoint_allocations"."updated_at" > "relay_managed_endpoint_allocations"."tunnel_released_at"', + const counted = yield* Effect.promise(() => + drizzle({ client: pg }) + .select({ environmentId: relayManagedEndpointAllocations.environmentId }) + .from(relayManagedEndpointAllocations) + .where( + ManagedTunnelLimits.countedAllocations({ + userId: "user-1", + environmentId: "self", + pendingCutoff: at(45), + environment: ne, + }), + ), ); - // A link revoked or made publish-only after the last reservation no longer holds a slot. - expect(query.sql).toContain( - '"relay_environment_links"."updated_at" >= "relay_managed_endpoint_allocations"."updated_at"', + + expect(counted.map((row) => row.environmentId).toSorted()).toEqual( + [...legacyCases, ...cases] + .filter((entry) => entry.counted) + .map((entry) => entry.environmentId) + .toSorted(), ); - }); - }); + }).pipe(Effect.provide(NodeServices.layer)), + ); it.effect("does not ask T3 Chat while the account is under its cap", () => Effect.gen(function* () { diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index e06bd883a2351..93aea073c71d3 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -47,6 +47,58 @@ export class ManagedTunnelLimitExceeded extends Schema.TaggedError + exists( + new QueryBuilder() + .select({ userId: relayEnvironmentLinks.userId }) + .from(relayEnvironmentLinks) + .where( + and( + eq(relayEnvironmentLinks.userId, allocation.userId), + eq(relayEnvironmentLinks.environmentId, allocation.environmentId), + or( + isNotNull(relayEnvironmentLinks.revokedAt), + eq(relayEnvironmentLinks.managedTunnelsEnabled, false), + ), + gte(relayEnvironmentLinks.updatedAt, since), + ), + ), + ); + +// Counts allocations, not links: a link is written only after its tunnel exists. +// `environment` is `ne` for the user's other environments or `eq` for this one, which relinks without counting itself. +// updatedAt moves only on provisioning writes and release, never on teardown claims, so a link +// withdrawn at or after it means cleanup is owed; an older withdrawal means a relink that never finished. +export const countedAllocations = (input: { + readonly userId: string; + readonly environmentId: string; + readonly pendingCutoff: string; + readonly environment: typeof eq; +}) => + and( + eq(allocation.userId, input.userId), + input.environment(allocation.environmentId, input.environmentId), + or( + // Reserved but not yet holding a tunnel. Release writes updatedAt = tunnelReleasedAt and + // a relink reserves before rewriting its link, so only a newer reservation counts here. + and( + gt(allocation.updatedAt, input.pendingCutoff), + or( + isNull(allocation.tunnelReleasedAt), + gt(allocation.updatedAt, allocation.tunnelReleasedAt), + ), + not(withdrawnLink(allocation.updatedAt)), + ), + and( + isNotNull(allocation.tunnelId), + isNull(allocation.tunnelReleasedAt), + not(withdrawnLink(allocation.updatedAt)), + ), + ), + ); + export class ManagedTunnelLimits extends Context.Service< ManagedTunnelLimits, { @@ -119,53 +171,11 @@ const make = Effect.gen(function* () { minutes: MANAGED_TUNNEL_PENDING_WINDOW_MINUTES, }), ); - // Counts allocations, not links: a link is written only after its tunnel exists. - // Excludes this environment so relinking at the limit stays idempotent. - const allocation = relayManagedEndpointAllocations; - const withdrawnLink = (since?: typeof allocation.updatedAt) => - exists( - new QueryBuilder() - .select({ userId: relayEnvironmentLinks.userId }) - .from(relayEnvironmentLinks) - .where( - and( - eq(relayEnvironmentLinks.userId, allocation.userId), - eq(relayEnvironmentLinks.environmentId, allocation.environmentId), - or( - isNotNull(relayEnvironmentLinks.revokedAt), - eq(relayEnvironmentLinks.managedTunnelsEnabled, false), - ), - since === undefined ? undefined : gte(relayEnvironmentLinks.updatedAt, since), - ), - ), - ); const countSlots = (environment: typeof eq) => db .select({ slots: count() }) .from(allocation) - .where( - and( - eq(allocation.userId, input.userId), - environment(allocation.environmentId, input.environmentId), - or( - // Reserved but not yet holding a tunnel. Release writes updatedAt = tunnelReleasedAt and - // a relink reserves before rewriting its link, so only a newer reservation counts here. - and( - gt(allocation.updatedAt, pendingCutoff), - or( - isNull(allocation.tunnelReleasedAt), - gt(allocation.updatedAt, allocation.tunnelReleasedAt), - ), - not(withdrawnLink(allocation.updatedAt)), - ), - and( - isNotNull(allocation.tunnelId), - isNull(allocation.tunnelReleasedAt), - not(withdrawnLink()), - ), - ), - ), - ) + .where(countedAllocations({ ...input, pendingCutoff, environment })) .pipe( Effect.map((rows) => rows[0]?.slots ?? 0), Effect.mapError( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5889ab5607c79..6bdece6bd1839 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,9 @@ importers: '@effect/vitest': specifier: 4.0.5 version: 4.0.5(patch_hash=2731c9c3761c595f25ddc625a7d61ca376b389d5b3049ecb837c8940f21cfa02)(@astrojs/compiler-rs@0.5.1)(@tsc-rs/typescript@0.8.0)(@types/node@24.12.4)(@vitest/browser-playwright@5.0.5)(@vitest/ui@5.0.5)(@volar/typescript@2.4.28(@tsc-rs/typescript@0.8.0))(bufferutil@4.1.0)(effect@4.0.5)(esbuild@0.28.2)(jiti@2.7.0)(jsdom@30.1.0(@noble/hashes@1.8.0))(playwright@1.60.0)(terser@5.48.0)(unrun@0.2.39)(utf-8-validate@6.0.6)(yaml@2.9.0) + '@electric-sql/pglite': + specifier: 0.3.15 + version: 0.3.15 '@types/node': specifier: 24.12.4 version: 24.12.4 @@ -13567,8 +13570,7 @@ snapshots: - vue-tsc - yaml - '@electric-sql/pglite@0.3.15': - optional: true + '@electric-sql/pglite@0.3.15': {} '@electron-internal/extract-zip@1.0.5': {} From cadbdafc063e562626843aaea64553f791c890b4 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 00:38:33 -0700 Subject: [PATCH 196/317] test(web): attachment preview tests wait long enough for cold workers and fonts (#26342) Co-authored-by: Claude Opus 5.5 --- .../AttachmentFilePreview.browser.test.tsx | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 5fd8b05505282..9879edfeedbd8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -2154,8 +2154,10 @@ it("refreshes authorization and retries a failed streamed attachment", async () asset={{ environmentId: EnvironmentId.make("test"), attachmentId: "retry" }} />, ); - await vi.waitFor(() => - expect(screen.container.textContent).toContain("This file contains binary data."), + // A cold large-text worker can take longer than waitFor's default to boot on CI. + await vi.waitFor( + () => expect(screen.container.textContent).toContain("This file contains binary data."), + { timeout: 10_000 }, ); await act(() => screen.container.querySelector('[role="alert"] button')!.click(), @@ -2217,7 +2219,10 @@ it("copies every indexed line rather than the mounted preview rows", async () => , ); await vi.waitFor(() => expect(screen.container.textContent).toContain("line-0")); - await vi.waitFor(async () => expect(await copyContents.current!()).toBe(text)); + // Copy rejects until the worker finishes indexing, which a cold worker can delay past waitFor's default on CI. + await vi.waitFor(async () => expect(await copyContents.current!()).toBe(text), { + timeout: 10_000, + }); const first = copyContents.current!(); await expect(copyContents.current!()).rejects.toThrow("Another copy is in progress."); expect(await first).toBe(text); @@ -2284,8 +2289,6 @@ it("loads a font attachment through the lazy preview", async () => { const { default: fontUrl } = await import("../../../node_modules/@betteroffice/fonts/assets/Carlito-Regular.ttf?url"); const file = await (await fetch(fontUrl)).blob(); - // Both lazy chunks can take longer than waitFor allows to load cold on CI. - await Promise.all([import("./FontFilePreview"), import("./FontViewer")]); await using screen = await render( { file={file} />, ); - await vi.waitFor(() => { - const frame = screen.container.querySelector("iframe"); - expect(frame?.title).toBe("Carlito.ttf"); - expect(frame?.srcdoc).toContain("@font-face"); - expect(frame?.srcdoc).toContain("The quick brown fox"); - }); + // Two cold lazy chunks plus decoding a 600 KB font can take longer than waitFor's default on CI. + await vi.waitFor( + () => { + const frame = screen.container.querySelector("iframe"); + expect(frame?.title).toBe("Carlito.ttf"); + expect(frame?.srcdoc).toContain("@font-face"); + expect(frame?.srcdoc).toContain("The quick brown fox"); + }, + { timeout: 10_000 }, + ); expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); From 2349c81afc1ce5de71fdb50f488f395f0307e4a2 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 00:39:19 -0700 Subject: [PATCH 197/317] refactor(mobile): app link parsing reads top to bottom with pipe (#26330) Co-authored-by: Claude Opus 5.5 --- apps/mobile/src/App.tsx | 37 ++++++++++-- apps/mobile/src/lib/appLinkState.test.ts | 72 ------------------------ apps/mobile/src/lib/appLinkState.ts | 27 --------- 3 files changed, 32 insertions(+), 104 deletions(-) delete mode 100644 apps/mobile/src/lib/appLinkState.test.ts delete mode 100644 apps/mobile/src/lib/appLinkState.ts diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 78299a2aa480c..225496bb7a407 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -13,9 +13,11 @@ import { createPathConfigForStaticNavigation, createStaticNavigation, getActionFromState, + getStateFromPath, type NavigationAction, type NavigationState, } from "@react-navigation/native"; +import { pipe } from "effect/Function"; import { RegistryContext } from "@effect/atom-react"; import { ProjectChipIconRasterizer } from "./native/ProjectChipIconImages"; @@ -48,12 +50,22 @@ import { subscribeComposerDraftModelReconciliation } from "./state/use-composer- import { environmentThreadShells } from "./state/threads"; import { environmentShell } from "./state/shell"; import { OverlayPortalHost } from "./components/ui/OverlayPortal"; -import { shouldHandleAppLink } from "./lib/appLinking"; +import { shouldHandleAppLink, restoreDraftLinkState } from "./lib/appLinking"; import { hasCloudPublicConfig } from "./features/cloud/publicConfig"; -import { pairingFormLinkAction } from "./features/connection/devAutoConnect"; -import { resolvePendingNewTaskPromptLink } from "./features/prompt-links/newTaskPromptLink"; -import { resolvePendingThreadPromptLink } from "./features/prompt-links/threadPromptLink"; -import { getAppStateFromPath } from "./lib/appLinkState"; +import { takeScheduledTaskNewLink } from "./features/settings/scheduled-task-editor-state"; +import { + pairingFormLinkAction, + startDevAutoConnect, + withoutPairingTarget, +} from "./features/connection/devAutoConnect"; +import { + resolvePendingNewTaskPromptLink, + takeNewTaskPromptLink, +} from "./features/prompt-links/newTaskPromptLink"; +import { + resolvePendingThreadPromptLink, + takeThreadPromptLink, +} from "./features/prompt-links/threadPromptLink"; import { disposeOnFoundationReplace, type FoundationHotModule, @@ -63,10 +75,12 @@ import { installUiRuntimeMemoryWarningGc } from "./lib/useUiRuntimeMemoryWarning import { SubscriptionUsageCoordinator } from "./widgets/SubscriptionUsageCoordinator"; import { installSubscriptionUsageRefresh } from "./widgets/useSubscriptionUsage"; import { GlobalVoiceInputControl } from "./features/voice-input/GlobalVoiceInputControl"; +import { restoreSettingsLinkState } from "./features/settings/settings-navigation"; import { hydrateWorkspaceSelections, launchWorkspaceURL, recordWorkspaceSelections, + settingsLinkPage, } from "./features/workspace/workspace-selection-store"; import { workspaceDetailLinkAction } from "./features/workspace/workspace-detail-selection"; import { NativeLayoutMetricsProvider } from "./native/native-layout-metrics"; @@ -94,6 +108,19 @@ const linkingConfig = { screens: createPathConfigForStaticNavigation(RootStack, { initialRouteName: "Home" }) ?? {}, } as const; +function getAppStateFromPath(path: string, options: Parameters[1]) { + return pipe( + getStateFromPath(path, options), + (state) => restoreDraftLinkState(state, path), + (state) => restoreSettingsLinkState(state, settingsLinkPage()), + takeThreadPromptLink, + takeNewTaskPromptLink, + withoutPairingTarget, + startDevAutoConnect, + takeScheduledTaskNewLink, + ); +} + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], config: { initialRouteName: "Home" }, diff --git a/apps/mobile/src/lib/appLinkState.test.ts b/apps/mobile/src/lib/appLinkState.test.ts deleted file mode 100644 index 9b907df800597..0000000000000 --- a/apps/mobile/src/lib/appLinkState.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import * as NodeModule from "node:module"; -import { expect, it, vi } from "vite-plus/test"; - -const harness = vi.hoisted(() => { - vi.stubGlobal("__DEV__", true); - return { pair: vi.fn() }; -}); - -// The native package pulls in React Native; its link parsing and actions live in core and routers. -vi.mock("@react-navigation/native", () => { - const requireFromNative = NodeModule.createRequire( - NodeModule.createRequire(import.meta.url).resolve("@react-navigation/native/package.json"), - ); - const requireFromCore = NodeModule.createRequire( - requireFromNative.resolve("@react-navigation/core/package.json"), - ); - const { getStateFromPath } = requireFromNative("@react-navigation/core"); - const { CommonActions, StackActions } = requireFromCore("@react-navigation/routers"); - return { getStateFromPath, CommonActions, StackActions }; -}); - -vi.mock("./rootNavigation", () => ({ - rootNavigationRef: { isReady: () => false }, - whenRootNavigationReady: () => {}, -})); -vi.mock("../components/ui/Dialog", () => ({ alert: () => {} })); -vi.mock("../features/prompt-links/prompt-link-actions", () => ({})); -// Stores the prompt-link steps read only when a link carries a prompt; they load the native runtime. -vi.mock("../state/projects", () => ({})); -vi.mock("../state/pull-requests", () => ({})); -vi.mock("../state/server", () => ({})); -vi.mock("../state/threads", () => ({})); -vi.mock("../state/use-composer-drafts", () => ({})); -vi.mock("../state/workspace", () => ({})); -vi.mock("../state/use-remote-environment-registry", () => ({ pairEnvironment: harness.pair })); -vi.mock("../features/workspace/workspace-selection-store", () => ({ - settingsLinkPage: () => undefined, - workspaceHasColumns: () => false, -})); - -import { findLinkedRoute } from "./appLinking"; -import { getAppStateFromPath } from "./appLinkState"; - -const PAIRING_URL = "http://192.168.1.10:3773/pair#token=abc"; - -// RootStack imports every screen, which Node cannot load; these are its paths for the pairing form. -const linking = { - initialRouteName: "Home", - screens: { - Home: "", - Settings: "settings", - SettingsEnvironmentNew: "settings/environment-new", - ConnectionsNew: "connections/new", - }, -}; - -it("a link cannot aim a pairing at a saved environment", () => { - harness.pair.mockResolvedValue({ ok: false, error: null }); - for (const path of ["connections/new", "settings/environment-new"]) { - harness.pair.mockClear(); - const state = getAppStateFromPath( - `${path}?routeFor=victim&pairingUrl=${encodeURIComponent(PAIRING_URL)}&autoConnect=1`, - linking, - ); - - expect(findLinkedRoute(state, ["ConnectionsNew", "SettingsEnvironmentNew"])?.params).toEqual({ - pairingUrl: PAIRING_URL, - autoConnect: "1", - }); - expect(harness.pair).toHaveBeenCalledExactlyOnceWith(PAIRING_URL); - } -}); diff --git a/apps/mobile/src/lib/appLinkState.ts b/apps/mobile/src/lib/appLinkState.ts deleted file mode 100644 index f404819f68f89..0000000000000 --- a/apps/mobile/src/lib/appLinkState.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { getStateFromPath } from "@react-navigation/native"; - -import { startDevAutoConnect, withoutPairingTarget } from "../features/connection/devAutoConnect"; -import { takeNewTaskPromptLink } from "../features/prompt-links/newTaskPromptLink"; -import { takeThreadPromptLink } from "../features/prompt-links/threadPromptLink"; -import { takeScheduledTaskNewLink } from "../features/settings/scheduled-task-editor-state"; -import { restoreSettingsLinkState } from "../features/settings/settings-navigation"; -import { settingsLinkPage } from "../features/workspace/workspace-selection-store"; -import { restoreDraftLinkState } from "./appLinking"; - -/** Parses an app link into navigation state, taking each link-owned side effect on the way. */ -export function getAppStateFromPath(path: string, options: Parameters[1]) { - return takeScheduledTaskNewLink( - startDevAutoConnect( - withoutPairingTarget( - takeNewTaskPromptLink( - takeThreadPromptLink( - restoreSettingsLinkState( - restoreDraftLinkState(getStateFromPath(path, options), path), - settingsLinkPage(), - ), - ), - ), - ), - ), - ); -} From ce66aab13fabde01b82deac4b4b3eaae53641988 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 00:40:10 -0700 Subject: [PATCH 198/317] ci(release): Windows CLI builds retry the Node.js download (#26345) Co-authored-by: Claude Opus 5.5 --- .github/workflows/release-desktop.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index ca418467f6b9c..3383bb0e80bb8 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -254,6 +254,24 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + # tsdown downloads the target Node with one fetch and no retry, and + # nodejs.org drops that download often enough to fail releases. Seed + # tsdown's cache with a retried, checksum-verified copy instead. + - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: | + set -euo pipefail + version="$(node -p 'require("./package.json").engines.node')" + name="node-v$version-win-${{ inputs.arch }}" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$version/win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" "$dest" + curl -fsSL --retry 5 --retry-all-errors -o "$dir/$name.zip" "https://nodejs.org/dist/v$version/$name.zip" + curl -fsSL --retry 5 --retry-all-errors -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$version/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" + # Setup Vite+ selects the exact engines.node version from package.json. # Native builds embed that executable; cross-target builds download the # same version for their target. From 990e9e06e59c9b06317670a08ff0dca5859cb7d8 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 02:41:07 -0500 Subject: [PATCH 199/317] fix(web): preserve handoffs before superseded attempt folds (#26333) --- .../components/chat/MessagesTimeline.logic.test.ts | 11 +++++++++++ .../web/src/components/chat/MessagesTimeline.logic.ts | 1 + 2 files changed, 12 insertions(+) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index dfc9c31fb3a7d..aa082a268667d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2642,6 +2642,15 @@ describe("v2 run and attempt history", () => { completedAt: null, }; const timelineEntries = [ + { + id: "context-handoff-entry", + kind: "event" as const, + createdAt: "2026-01-01T00:00:00Z", + attempt: supersededAttempt, + projectedItem: { + item: { type: "handoff", runId }, + } as never, + }, { id: "initial-user-entry", kind: "message" as const, @@ -2744,6 +2753,7 @@ describe("v2 run and attempt history", () => { const collapsedRows = deriveMessagesTimelineRows(common); expect(collapsedRows.map((row) => row.id)).toEqual([ + "context-handoff-entry", "initial-user-entry", `attempt-fold:${supersededAttemptId}`, "superseded-thread-created-entry", @@ -2762,6 +2772,7 @@ describe("v2 run and attempt history", () => { expandedAttemptIds: new Set([supersededAttemptId]), }); expect(expandedRows.map((row) => row.id)).toEqual([ + "context-handoff-entry", "initial-user-entry", `attempt-fold:${supersededAttemptId}`, "superseded-assistant-entry", diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index f771c1ed0d072..ac9db2f7dd794 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -770,6 +770,7 @@ function deriveSupersededAttemptFolds( completeAttemptIds.has(entry.attempt.id) || unfoldedRunIds.has(entry.attempt.runId) || (entry.kind === "message" && entry.message.role === "user") || + (entry.kind === "event" && entry.projectedItem.item.type === "handoff") || entry.kind === "html-render" || entry.kind === "mcp-app" || timelineEntryIsPersistentResourceCard(entry) || From 441a4ebd65543fa414797253eb31cc02f8811e32 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 02:42:27 -0500 Subject: [PATCH 200/317] feat(web): open tool call diffs in the diff panel (#26304) --- .../Adapters/AcpAdapterV2.test.ts | 20 ++- .../orchestration-v2/WireProjection.test.ts | 9 + .../src/orchestration-v2/WireProjection.ts | 14 +- apps/web/src/components/ChatView.tsx | 2 + apps/web/src/components/DiffPanel.tsx | 157 +++++++++++++----- .../src/components/chat/MessagesTimeline.tsx | 4 + .../components/chat/TimelineWorkEntryRow.tsx | 2 +- .../src/components/chat/V2ItemInspector.tsx | 15 +- .../src/components/chat/timelineRowContext.ts | 2 + .../components/chat/useRightPanelActions.ts | 11 ++ apps/web/src/diffPanelStore.test.ts | 76 ++++++++- apps/web/src/diffPanelStore.ts | 31 +++- apps/web/src/lib/toolCallDiff.test.ts | 132 +++++++++++++++ apps/web/src/lib/toolCallDiff.ts | 77 +++++++++ packages/provider-acp/src/server/adapter.ts | 19 ++- 15 files changed, 520 insertions(+), 51 deletions(-) create mode 100644 apps/web/src/lib/toolCallDiff.test.ts create mode 100644 apps/web/src/lib/toolCallDiff.ts diff --git a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts index 92eb5b1047ff8..f05ace7ff750d 100644 --- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.test.ts @@ -111,7 +111,25 @@ const ACP_TEST_DRIVER = ProviderDriverKind.make("acp-test"); const decodeUnknownJson = Schema.decodeUnknownOption(Schema.fromJsonString(Schema.Unknown)); describe("acpToolCallDiffPatch", () => { - it("builds a patch per file from ACP v1 oldText/newText, with /dev/null for a new file", () => { + it("builds per-file patches from ACP hunks and recorded old/new text", () => { + for (const operation of ["add", "delete"]) { + const text = operation === "add" ? "@@ -0,0 +1 @@\n+content\n" : "@@ -1 +0,0 @@\n-content\n"; + assert.equal( + acpToolCallDiffPatch([ + { + type: "diff", + changes: [{ operation, path: "/repo/a.ts" }], + patch: { format: "git_patch", text }, + }, + ]), + [ + 'diff --git "a//repo/a.ts" "b//repo/a.ts"', + operation === "add" ? "--- /dev/null" : '--- "a//repo/a.ts"', + operation === "delete" ? "+++ /dev/null" : '+++ "b//repo/a.ts"', + text, + ].join("\n"), + ); + } assert.equal( acpToolCallDiffPatch([ { type: "diff", path: "/repo/new.txt", oldText: null, newText: "hello\n" }, diff --git a/apps/server/src/orchestration-v2/WireProjection.test.ts b/apps/server/src/orchestration-v2/WireProjection.test.ts index baf1ea0a3cb77..d449aeb933dbf 100644 --- a/apps/server/src/orchestration-v2/WireProjection.test.ts +++ b/apps/server/src/orchestration-v2/WireProjection.test.ts @@ -275,6 +275,15 @@ describe("orchestration V2 wire projection", () => { expect(projected).not.toHaveProperty("newStr"); expect(projected).toMatchObject({ fileName: "src/main.ts", additions: 3, deletions: 1 }); expect(item.diffStr).toBe("+new code"); + expect(projectTurnItemForDetail(item)).toMatchObject({ diffStr: "+new code" }); + expect(projectTurnItemForDetail(item)).not.toHaveProperty("oldStr"); + expect(projectTurnItemForDetail(item)).not.toHaveProperty("newStr"); + const { diffStr: _diff, ...contentOnly } = item; + expect(projectTurnItemForDetail(contentOnly)).toMatchObject({ + oldStr: "old code", + newStr: "new code", + }); + expect(projectTurnItemForWire(contentOnly)).not.toHaveProperty("newStr"); const failed = projectTurnItemForWire({ ...item, status: "failed", diff --git a/apps/server/src/orchestration-v2/WireProjection.ts b/apps/server/src/orchestration-v2/WireProjection.ts index a84f8cd466859..6b66f1b91f204 100644 --- a/apps/server/src/orchestration-v2/WireProjection.ts +++ b/apps/server/src/orchestration-v2/WireProjection.ts @@ -200,8 +200,20 @@ export function projectTurnItemForDetail(item: OrchestrationV2TurnItem): Orchest result: item.result === null ? null : (truncateDetail(item.result, MAX_ON_DEMAND_BYTES) ?? null), }; - case "handoff": case "file_change": + return { + ...projectTurnItemForWire(item), + ...(item.diffStr === undefined + ? {} + : { diffStr: truncateDetail(item.diffStr, MAX_ON_DEMAND_BYTES) }), + ...(!item.diffStr?.trim() && item.oldStr !== undefined + ? { oldStr: truncateDetail(item.oldStr, MAX_ON_DEMAND_BYTES) } + : {}), + ...(!item.diffStr?.trim() && item.newStr !== undefined + ? { newStr: truncateDetail(item.newStr, MAX_ON_DEMAND_BYTES) } + : {}), + }; + case "handoff": return projectTurnItemForWire(item); default: return item; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 4796a8465e244..c6d9cfd534b86 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -2323,6 +2323,7 @@ export default function ChatView(props: ChatViewProps) { createBrowserSurface, createBrowserSurfaceStable, moveRightPanelSurfaceStable, + onOpenToolDiffStable, onOpenTurnDiffStable, openChangesFromThreadPanelStable, openFileSurfaceStable, @@ -3309,6 +3310,7 @@ export default function ChatView(props: ChatViewProps) { routeThreadKey={displayedTimelineKey} displayThreadKey={displayedTimelineKey} announceTurnStatus={!isProviderSubagent} + onOpenToolDiff={onOpenToolDiffStable} onOpenTurnDiff={onOpenTurnDiffStable} onOpenThread={onOpenRelatedThreadStable} parentThreadLink={parentThreadLink} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 53af7b6e92e44..c257d0ea52d34 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -26,10 +26,12 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; import { useFileContextMenuHandler } from "../fileContextMenu"; import { type DraftId } from "../composerDraftStore"; import { openDiffFilePrimaryAction } from "../diffFileActions"; -import { useCheckpointDiff } from "~/state/queries"; +import { toolCallDiff } from "../lib/toolCallDiff"; +import { useCheckpointDiff, useTurnItemDetail } from "~/state/queries"; import { revisionedQuery } from "~/state/revisionedQuery"; import { cn } from "~/lib/utils"; import { + resolveToolCallDiffTarget, selectThreadDiffPanelSelection, useDiffPanelSessionStore, useDiffPanelStore, @@ -48,7 +50,12 @@ import { import { PREFERRED_HIGHLIGHTER } from "../lib/syntaxHighlighting"; import { areAllDiffFilesCollapsed, toggleAllDiffFiles } from "../lib/diffCollapse"; import { LIVE_REFRESH_MIN_INTERVAL_MS, useLiveRefresh } from "../hooks/useLiveRefresh"; -import { useProject, useThreadCheckpointSummaries, useQuietThreadShell } from "../state/entities"; +import { + useProject, + useThreadCheckpointSummaries, + useQuietThreadShell, + useQuietThreadVisibleTurnItems, +} from "../state/entities"; import { useClientSettings, updateClientSettings } from "../hooks/useSettings"; import { formatShortTimestamp } from "../timestampFormat"; import { DiffFilePathCopyButton } from "./DiffFilePathCopyButton"; @@ -252,6 +259,18 @@ export default function DiffPanel({ const diffSelection = useDiffPanelStore((state) => selectThreadDiffPanelSelection(state.byThreadKey, routeThreadRef), ); + const toolCallSelection = diffSelection.kind === "tool-call" ? diffSelection : null; + const isGitSelection = diffSelection.kind === "branch" || diffSelection.kind === "unstaged"; + const toolCallItems = useQuietThreadVisibleTurnItems(toolCallSelection ? routeThreadRef : null); + const toolCallDetail = useTurnItemDetail( + toolCallSelection && routeThreadRef + ? { + ...resolveToolCallDiffTarget(toolCallSelection, toolCallItems), + environmentId: routeThreadRef.environmentId, + } + : null, + ); + const toolCallItem = toolCallDetail.data?.item; const isGitRepo = gitStatusQuery.data?.isRepo ?? true; const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadRef); const orderedTurnDiffSummaries = turnDiffSummaries.toSorted((left, right) => { @@ -283,24 +302,31 @@ export default function DiffPanel({ orderedTurnDiffSummaries[0]); const selectedCheckpointTurnCount = selectedTurn?.checkpointTurnCount; const latestTurn = orderedTurnDiffSummaries[0]; - const selectedScopeLabel = - selectedRunId === null + const selectedScopeLabel = toolCallSelection + ? "Tool call" + : selectedRunId === null ? selectedGitScope === "unstaged" ? "Uncommitted" : "Changes" : selectedTurn?.runId === latestTurn?.runId ? "Latest turn" : `Turn ${selectedCheckpointTurnCount ?? "?"}`; - const reviewSectionId = selectedTurn ? `turn:${selectedTurn.runId}` : selectedGitScope; + const reviewSectionId = toolCallSelection + ? `tool-call:${toolCallSelection.threadId}:${toolCallSelection.itemId}` + : selectedTurn + ? `turn:${selectedTurn.runId}` + : selectedGitScope; const collapseScopeKey = routeThreadRef ? `${routeThreadRef.environmentId}:${routeThreadRef.threadId}:${reviewSectionId}` : null; const codeViewMountKey = `${collapseScopeKey ?? reviewSectionId}:${codeViewRevision}`; - const reviewSectionTitle = selectedTurn - ? `Turn ${selectedCheckpointTurnCount ?? "?"}` - : selectedGitScope === "unstaged" - ? "Uncommitted" - : "Changes"; + const reviewSectionTitle = toolCallSelection + ? "Tool call" + : selectedTurn + ? `Turn ${selectedCheckpointTurnCount ?? "?"}` + : selectedGitScope === "unstaged" + ? "Uncommitted" + : "Changes"; const selectedCheckpointRange = typeof selectedCheckpointTurnCount === "number" ? { @@ -320,7 +346,7 @@ export default function DiffPanel({ { enabled: isGitRepo && selectedTurn !== undefined }, ); const branchDiffPreviewInput = - canReadFiles && selectedRunId === null && activeThread && activeCwd + canReadFiles && isGitSelection && activeThread && activeCwd ? { cwd: activeCwd, ...(selectedBaseRef ? { baseRef: selectedBaseRef } : {}), @@ -328,7 +354,7 @@ export default function DiffPanel({ } : undefined; const canRefreshGitDiff = - isGitRepo && selectedRunId === null && activeThread != null && activeCwd != null; + isGitRepo && isGitSelection && activeThread != null && activeCwd != null; // Read under the workspace mutation id, so each mutation refetches the diff; the panel stays // mounted while hidden, so a mutation made then is already reflected when it shows. const branchDiffPreview = useEnvironmentQuery( @@ -355,25 +381,52 @@ export default function DiffPanel({ const preview = branchDiffPreview.data; const gitDiff = selectedGitSource?.diff; - const selectedPatch = selectedTurn ? activeCheckpointDiff.data?.diff : gitDiff; - const isSelectedPatchTruncated = !selectedTurn && selectedGitSource?.truncated === true; - const selectedPatchQuery = selectedTurn ? activeCheckpointDiff : branchDiffPreview; + const toolCallPreview = + toolCallSelection && toolCallItem?.type === "file_change" ? toolCallDiff(toolCallItem) : null; + const selectedPatch = toolCallSelection + ? toolCallPreview?.patch + : selectedTurn + ? activeCheckpointDiff.data?.diff + : gitDiff; + const isSelectedPatchTruncated = + toolCallPreview?.truncated === true || + (isGitSelection && selectedGitSource?.truncated === true); + const selectedPatchQuery = toolCallSelection + ? toolCallDetail + : selectedTurn + ? activeCheckpointDiff + : branchDiffPreview; const isLoadingSelectedPatch = selectedPatchQuery.isPending && selectedPatchQuery.data === null; - const selectedPatchError = selectedTurn ? activeCheckpointDiff.error : branchDiffPreview.error; + const selectedPatchError = toolCallSelection + ? toolCallDetail.error + : selectedTurn + ? activeCheckpointDiff.error + : branchDiffPreview.error; const hasResolvedPatch = typeof selectedPatch === "string"; const hasNoNetChanges = hasResolvedPatch && selectedPatch.trim().length === 0; const lazySource = - !selectedTurn && selectedGitSource?.truncated && selectedGitSource.files + isGitSelection && selectedGitSource?.truncated && selectedGitSource.files ? selectedGitSource : null; const renderablePatch = getRenderablePatch( lazySource ? undefined : selectedPatch, `diff-panel:${resolvedTheme}`, - { compactPartialHunkOffsets: selectedRunId === null }, + { + compactPartialHunkOffsets: isGitSelection, + ...(toolCallSelection ? { ignoreWhitespace: diffIgnoreWhitespace } : {}), + }, ); + const recordedToolCallFileCount = + toolCallSelection && toolCallItem?.type === "file_change" + ? (toolCallItem.changes?.length ?? 0) + : 0; + const omittedToolCallFiles = + renderablePatch?.kind === "files" + ? Math.max(0, recordedToolCallFileCount - renderablePatch.files.length) + : 0; const fileStats = new Map(lazySource?.files?.map((file) => [file.path, file])); const sourceLineStat = - !selectedTurn && selectedGitSource?.files + isGitSelection && selectedGitSource?.files ? selectedGitSource.files.reduce( (total, file) => ({ additions: total.additions + file.additions, @@ -383,7 +436,7 @@ export default function DiffPanel({ ) : null; const currentLoadDiffFiles = - selectedRunId === null && activeThread && preview && selectedGitSource && canReadFiles + isGitSelection && activeThread && preview && selectedGitSource && canReadFiles ? createGitDiffFileContentsLoader(getDiffFileContents, { environmentId: activeThread.environmentId, cwd: preview.cwd, @@ -400,10 +453,7 @@ export default function DiffPanel({ return currentLoadDiffFiles(fileDiff); }); const localBranchRefs = useEnvironmentQuery( - selectedRunId === null && - selectedGitScope === "branch" && - activeThread && - branchDiffPreview.data?.cwd + isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { @@ -417,10 +467,7 @@ export default function DiffPanel({ : null, ); const remoteBranchRefs = useEnvironmentQuery( - selectedRunId === null && - selectedGitScope === "branch" && - activeThread && - branchDiffPreview.data?.cwd + isGitSelection && selectedGitScope === "branch" && activeThread && branchDiffPreview.data?.cwd ? vcsEnvironment.listRefs({ environmentId: activeThread.environmentId, input: { @@ -630,8 +677,9 @@ export default function DiffPanel({ useDiffPanelStore.getState().selectBranchBaseRef(routeThreadRef, baseRef); }; const selectedTurnValue = selectedTurn ? `turn:${selectedTurn.runId}` : ""; - const selectedScopeValue = - selectedRunId === null + const selectedScopeValue = toolCallSelection + ? "" + : selectedRunId === null ? selectedGitScope : selectedTurn?.runId === latestTurn?.runId ? "latest" @@ -698,7 +746,7 @@ export default function DiffPanel({ - {selectedRunId === null && selectedGitScope === "branch" && selectedGitSource?.baseRef && ( + {isGitSelection && selectedGitScope === "branch" && selectedGitSource?.baseRef && (
    - {codeViewFiles.length > 0 || (!selectedTurn && selectedGitSource?.files?.length) ? ( + {codeViewFiles.length > 0 || (isGitSelection && selectedGitSource?.files?.length) ? ( Select a thread to inspect turn diffs.
    - ) : !isGitRepo ? ( + ) : !isGitRepo && !toolCallSelection ? (
    Turn diffs are unavailable because this project is not a git repository.
    @@ -981,7 +1029,7 @@ export default function DiffPanel({
    No completed turns yet.
    - ) : selectedRunId === null && !canReadFiles ? ( + ) : isGitSelection && !canReadFiles ? ( fileAccess.isPending ? ( ) : ( @@ -995,7 +1043,13 @@ export default function DiffPanel({ {isSelectedPatchTruncated && !lazySource && (

    This preview exceeds the size limit. Changes shown are incomplete. - {selectedGitSource?.files ? " Totals include all changes." : ""} + {isGitSelection && selectedGitSource?.files ? " Totals include all changes." : ""} +

    + )} + {omittedToolCallFiles > 0 && ( +

    + This tool call changed {recordedToolCallFileCount} files. The recorded patch omits{" "} + {omittedToolCallFiles} of them.

    )} {selectedPatchError && !renderablePatch && ( @@ -1003,15 +1057,36 @@ export default function DiffPanel({

    {selectedPatchError}

    )} - {!renderablePatch && !lazySource ? ( + {!renderablePatch && toolCallPreview && toolCallPreview.content.length > 0 ? ( +
    +

    + The provider recorded file content without a diff. +

    + {toolCallPreview.content.map(({ label, text }) => ( +
    +

    {label}

    +
    +                      {text === "" ? "Empty content." : text}
    +                    
    +
    + ))} +
    + ) : !renderablePatch && !lazySource ? ( isLoadingSelectedPatch ? ( ) : ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a166a25e47766..c7a38de8fbed6 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,4 @@ +import type { ToolCallDiffTarget } from "../../diffPanelStore"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; import { MarkdownFindContext } from "./markdownFindContext"; @@ -241,6 +242,7 @@ interface MessagesTimelineProps { displayThreadKey?: string; /** Off where another region already speaks the thread's status, like a subagent's bar. */ announceTurnStatus?: boolean; + onOpenToolDiff?: (target: ToolCallDiffTarget) => void; onOpenTurnDiff?: (runId: RunId, filePath?: string) => void; onOpenThread: (threadId: OrchestrationV2TurnItem["threadId"]) => void; renderHtmlRender?: TimelineRowSharedState["renderHtmlRender"]; @@ -350,6 +352,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ routeThreadKey, displayThreadKey, announceTurnStatus = true, + onOpenToolDiff, onOpenTurnDiff, onOpenThread, renderHtmlRender, @@ -1053,6 +1056,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ onAppFullscreenChange, onFileDownload, openPullRequest, + onOpenToolDiff, onOpenTurnDiff, onOpenThread, renderHtmlRender, diff --git a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx index 94d001be7e551..a8d5e3e68de77 100644 --- a/apps/web/src/components/chat/TimelineWorkEntryRow.tsx +++ b/apps/web/src/components/chat/TimelineWorkEntryRow.tsx @@ -536,7 +536,7 @@ function WorkEntryLogRow(props: WorkEntryRowProps) { environmentId={ctx.activeThreadEnvironmentId} cwd={ctx.markdownCwd} workspaceRoot={workspaceRoot} - onOpenTurnDiff={ctx.onOpenTurnDiff} + onOpenToolDiff={ctx.onOpenToolDiff} onImageExpand={ctx.onImageExpand} source={ctx.turnItemSource} /> diff --git a/apps/web/src/components/chat/V2ItemInspector.tsx b/apps/web/src/components/chat/V2ItemInspector.tsx index 958a56077e51a..ee6072b926b7e 100644 --- a/apps/web/src/components/chat/V2ItemInspector.tsx +++ b/apps/web/src/components/chat/V2ItemInspector.tsx @@ -1,4 +1,5 @@ -import type { EnvironmentId, OrchestrationV2ProjectedTurnItem, RunId } from "@t3tools/contracts"; +import type { ToolCallDiffTarget } from "../../diffPanelStore"; +import type { EnvironmentId, OrchestrationV2ProjectedTurnItem } from "@t3tools/contracts"; import { toolCallLines, turnItemDetailRevision, @@ -32,7 +33,7 @@ interface V2ItemInspectorProps { readonly environmentId: EnvironmentId; readonly cwd?: string | undefined; readonly workspaceRoot?: string | undefined; - readonly onOpenTurnDiff?: ((runId: RunId, filePath?: string) => void) | undefined; + readonly onOpenToolDiff?: ((target: ToolCallDiffTarget) => void) | undefined; readonly onImageExpand?: ((preview: ExpandedImagePreview) => void) | undefined; readonly source?: TurnItemSource | undefined; } @@ -289,11 +290,17 @@ export const V2ItemInspector = memo(function V2ItemInspector(props: V2ItemInspec -{formatDiffCount(item.deletions ?? 0)}
    ) : null} - {item.runId !== null && props.onOpenTurnDiff ? ( + {props.onOpenToolDiff ? ( diff --git a/apps/web/src/components/chat/timelineRowContext.ts b/apps/web/src/components/chat/timelineRowContext.ts index b3677b33de76c..6334ef53bd050 100644 --- a/apps/web/src/components/chat/timelineRowContext.ts +++ b/apps/web/src/components/chat/timelineRowContext.ts @@ -1,3 +1,4 @@ +import type { ToolCallDiffTarget } from "../../diffPanelStore"; import type { ProviderApprovalDecision, RuntimeRequestId, @@ -67,6 +68,7 @@ export interface TimelineRowSharedState { findActive: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; displayThreadKey?: string; + onOpenToolDiff: ((target: ToolCallDiffTarget) => void) | undefined; /** Unset where there is no diff to open, so the timeline shows no "Open diff" button. */ onOpenTurnDiff: ((runId: RunId, filePath?: string) => void) | undefined; /** diff --git a/apps/web/src/components/chat/useRightPanelActions.ts b/apps/web/src/components/chat/useRightPanelActions.ts index ac79b0853bf1f..3919df03b6af3 100644 --- a/apps/web/src/components/chat/useRightPanelActions.ts +++ b/apps/web/src/components/chat/useRightPanelActions.ts @@ -1,3 +1,4 @@ +import type { ToolCallDiffTarget } from "../../diffPanelStore"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import type { ThreadPreviewState } from "../../previewStateStore"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; @@ -302,6 +303,15 @@ export function useRightPanelActions(input: RightPanelActionsInput) { useRightPanelStore.getState().open(activeThreadRef, "diff"); onDiffPanelOpen?.(); }; + const onOpenToolDiffStable = useStableCallback((target: ToolCallDiffTarget) => { + if (!isServerThread || !activeThreadRef) return; + const diffOpen = + selectActiveRightPanel(useRightPanelStore.getState().byThreadKey, activeThreadRef) === "diff"; + explicitDiffOpenRef.current = diffOpen ? null : activeThreadRef; + useDiffPanelStore.getState().selectToolCall(activeThreadRef, target); + useRightPanelStore.getState().open(activeThreadRef, "diff"); + onDiffPanelOpen?.(); + }); const onOpenTurnDiffStable = useStableCallback(onOpenTurnDiff); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the preview action bus to toggle the preview panel useEffect( @@ -338,6 +348,7 @@ export function useRightPanelActions(input: RightPanelActionsInput) { createBrowserSurface, createBrowserSurfaceStable, moveRightPanelSurfaceStable, + onOpenToolDiffStable, onOpenTurnDiffStable, onToggleDiffStable, openChangesFromThreadPanelStable, diff --git a/apps/web/src/diffPanelStore.test.ts b/apps/web/src/diffPanelStore.test.ts index b7238e8edf906..82065c86fe3aa 100644 --- a/apps/web/src/diffPanelStore.test.ts +++ b/apps/web/src/diffPanelStore.test.ts @@ -1,6 +1,13 @@ +import { EnvironmentId, ThreadId, TurnItemId } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { beforeEach, describe, expect, it } from "vite-plus/test"; -import { useDiffPanelSessionStore, useDiffPanelStore } from "./diffPanelStore"; +import { + resolveToolCallDiffTarget, + useDiffPanelSessionStore, + useDiffPanelStore, +} from "./diffPanelStore"; describe("diffPanelStore", () => { beforeEach(() => { @@ -11,6 +18,73 @@ describe("diffPanelStore", () => { useDiffPanelSessionStore.setState({ collapsedFileKeysByScopeKey: {} }); }); + it("keeps a tool call selection independent of checkpoints and allows returning to Git changes", () => { + const ref = { environmentId: EnvironmentId.make("env"), threadId: ThreadId.make("thread") }; + const target = { + threadId: ThreadId.make("child"), + itemId: TurnItemId.make("edit"), + revision: "1", + }; + const store = useDiffPanelStore.getState(); + store.selectToolCall(ref, target); + store.reconcileTurnSelection(ref, []); + expect(useDiffPanelStore.getState().byThreadKey[scopedThreadKey(ref)]).toEqual({ + kind: "tool-call", + ...target, + }); + store.selectGitScope(ref, "branch"); + expect(useDiffPanelStore.getState().byThreadKey[scopedThreadKey(ref)]).toEqual({ + kind: "branch", + baseRef: null, + }); + }); + + it("refreshes a live tool-call target when its source item completes", () => { + const target = { + threadId: ThreadId.make("child"), + itemId: TurnItemId.make("edit"), + revision: "live", + }; + const now = DateTime.makeUnsafe("2026-10-28T00:00:00Z"); + const row = { + position: 0, + visibility: "local" as const, + sourceThreadId: target.threadId, + sourceItemId: target.itemId, + item: { + id: target.itemId, + threadId: target.threadId, + runId: null, + nodeId: null, + providerThreadId: null, + providerTurnId: null, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "running" as const, + title: null, + startedAt: now, + completedAt: null, + updatedAt: now, + type: "file_change" as const, + fileName: "a.ts", + }, + }; + expect(resolveToolCallDiffTarget(target, [row])).toBe(target); + const completed = { + ...row, + item: { ...row.item, status: "completed" as const, completedAt: now }, + }; + expect(resolveToolCallDiffTarget(target, [completed])).toEqual({ + ...target, + revision: DateTime.formatIso(now), + }); + expect( + resolveToolCallDiffTarget(target, [{ ...completed, sourceThreadId: ThreadId.make("other") }]), + ).toBe(target); + expect(resolveToolCallDiffTarget(target, [])).toBe(target); + }); + it("retains fold choices when a panel consumer returns with fresh defaults", () => { const scopeKey = "environment-1:thread-1:branch"; const defaults = new Set(["src/app.ts", "src/index.ts"]); diff --git a/apps/web/src/diffPanelStore.ts b/apps/web/src/diffPanelStore.ts index eb6f0e9d455c8..11d96a3909ce7 100644 --- a/apps/web/src/diffPanelStore.ts +++ b/apps/web/src/diffPanelStore.ts @@ -1,11 +1,20 @@ +import type { ThreadStructureVisibleTurnItem } from "@t3tools/client-runtime/state/shell"; +import { turnItemDetailRevision } from "@t3tools/client-runtime/work-log/item-detail"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { RunId, ScopedThreadRef } from "@t3tools/contracts"; +import type { RunId, ScopedThreadRef, ThreadId, TurnItemId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; +export interface ToolCallDiffTarget { + readonly threadId: ThreadId; + readonly itemId: TurnItemId; + readonly revision: string; +} + export type DiffPanelSelection = + | ({ kind: "tool-call" } & ToolCallDiffTarget) | { kind: "branch"; baseRef: string | null } | { kind: "unstaged" } | { kind: "turn"; turnId: RunId; filePath: string | null; revealRequestId: number }; @@ -17,6 +26,7 @@ interface DiffPanelStoreState { branchBaseRefByThreadKey: Record; selectGitScope: (ref: ScopedThreadRef, scope: "branch" | "unstaged") => void; selectBranchBaseRef: (ref: ScopedThreadRef, baseRef: string | null) => void; + selectToolCall: (ref: ScopedThreadRef, target: ToolCallDiffTarget) => void; selectTurn: (ref: ScopedThreadRef, turnId: RunId, filePath?: string) => void; reconcileTurnSelection: (ref: ScopedThreadRef, availableTurnIds: ReadonlyArray) => void; removeThread: (ref: ScopedThreadRef) => void; @@ -69,6 +79,13 @@ export const useDiffPanelStore = create()( }, }; }), + selectToolCall: (ref, target) => + set((state) => ({ + byThreadKey: { + ...state.byThreadKey, + [scopedThreadKey(ref)]: { kind: "tool-call", ...target }, + }, + })), selectTurn: (ref, turnId, filePath) => set((state) => { const threadKey = scopedThreadKey(ref); @@ -160,3 +177,15 @@ export function selectThreadDiffPanelSelection( if (!ref) return DEFAULT_SELECTION; return byThreadKey[scopedThreadKey(ref)] ?? DEFAULT_SELECTION; } + +export function resolveToolCallDiffTarget( + target: ToolCallDiffTarget, + items: ReadonlyArray, +): ToolCallDiffTarget { + const row = items.find( + (row) => row.sourceThreadId === target.threadId && row.sourceItemId === target.itemId, + ); + if (row?.item.type !== "file_change") return target; + const revision = turnItemDetailRevision(row.item); + return revision === target.revision ? target : { ...target, revision }; +} diff --git a/apps/web/src/lib/toolCallDiff.test.ts b/apps/web/src/lib/toolCallDiff.test.ts new file mode 100644 index 0000000000000..cb33dd45cfb5a --- /dev/null +++ b/apps/web/src/lib/toolCallDiff.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getRenderablePatch, resolveFileDiffPath } from "./diffRendering"; +import { toolCallDiff } from "./toolCallDiff"; + +describe("toolCallDiff", () => { + it("renders recorded hunks without reading a repository, including renamed paths", () => { + for (const changes of [ + undefined, + [{ operation: "update", oldPath: "old.ts", path: "new.ts" }], + ]) { + const { patch } = toolCallDiff({ + fileName: "old.ts", + diffStr: "@@ -1 +1 @@\n-before\n+after\n", + ...(changes ? { changes } : {}), + }); + const rendered = getRenderablePatch(patch); + expect(rendered?.kind).toBe("files"); + if (rendered?.kind !== "files") throw new Error("Expected a file diff"); + expect(rendered.files).toHaveLength(1); + expect(resolveFileDiffPath(rendered.files[0]!)).toBe(changes ? "new.ts" : "old.ts"); + expect(rendered.files[0]!.additionLines.join("")).toContain("after"); + expect(rendered.files[0]!.deletionLines.join("")).toContain("before"); + } + }); + + it("renders added and deleted file contents as a file diff", () => { + for (const operation of ["add", "delete"]) { + for (const [content, ending] of [ + ["first\nsecond", ""], + ["@@ -0,0 +1 @@\n+content", "\n"], + ["@@ -1 +0,0 @@\n-content", "\n"], + ["first\nsecond", "\n"], + ["--- notes\nsecond", "\n"], + ["diff --git notes\nsecond", "\n"], + ["--- a/embedded.ts\n+++ b/embedded.ts\n@@ -1 +1 @@\n-before\n+after", "\n"], + [ + "diff --git a/embedded.ts b/embedded.ts\n--- a/embedded.ts\n+++ b/embedded.ts\n@@ -1 +1 @@\n-before\n+after", + "\n", + ], + ] as const) { + const recordedContent = `${content}${ending}`; + const rendered = getRenderablePatch( + toolCallDiff({ + fileName: "file with spaces.ts", + diffStr: recordedContent, + changes: [{ operation, path: "file with spaces.ts" }], + }).patch, + ); + expect(rendered?.kind).toBe("files"); + if (rendered?.kind !== "files") throw new Error("Expected a file diff"); + expect(resolveFileDiffPath(rendered.files[0]!)).toBe("file with spaces.ts"); + const file = rendered.files[0]!; + const lines = content.split("\n").length; + expect(file.additionLines).toHaveLength(operation === "add" ? lines : 0); + expect(file.deletionLines).toHaveLength(operation === "delete" ? lines : 0); + expect((operation === "add" ? file.additionLines : file.deletionLines).join("")).toBe( + recordedContent.replace(/\n$/, ""), + ); + } + } + }); + + it("keeps content-only writes out of the patch and preserves empty recorded content", () => { + for (const operation of ["add", "delete"]) { + expect( + toolCallDiff({ + fileName: "empty.ts", + diffStr: "", + changes: [{ operation, path: "empty.ts" }], + }).content, + ).toEqual([ + { + label: operation === "add" ? "Recorded content after" : "Recorded content before", + text: "", + }, + ]); + } + expect(toolCallDiff({ fileName: "a.ts", oldStr: "before", newStr: "" })).toEqual({ + patch: undefined, + content: [ + { label: "Recorded content before", text: "before" }, + { label: "Recorded content after", text: "" }, + ], + truncated: false, + }); + expect(toolCallDiff({ fileName: "a.ts", newStr: "after" }).content).toEqual([ + { label: "Recorded content after", text: "after" }, + ]); + }); + + it("removes transport truncation markers before converting file contents", () => { + const suffix = "\n… output truncated for transport"; + const diff = toolCallDiff({ + fileName: "a.ts", + diffStr: `first\nsecond${suffix}`, + changes: [{ operation: "add", path: "a.ts" }], + }); + expect(diff.truncated).toBe(true); + expect(diff.patch).not.toContain("output truncated"); + expect(getRenderablePatch(diff.patch)?.kind).toBe("files"); + expect(toolCallDiff({ fileName: "a.ts", newStr: `content${suffix}` })).toMatchObject({ + content: [{ label: "Recorded content after", text: "content" }], + truncated: true, + }); + }); + + it("preserves complete patches and unsupported provider output", () => { + for (const operation of ["add", "delete"]) { + const diffStr = [ + "diff --git a/a.ts b/a.ts", + `--- ${operation === "add" ? "/dev/null" : "a/a.ts"}`, + `+++ ${operation === "add" ? "b/a.ts" : "/dev/null"}`, + operation === "add" ? "@@ -0,0 +1 @@" : "@@ -1 +0,0 @@", + operation === "add" ? "+content" : "-content", + "", + ].join("\n"); + expect( + toolCallDiff({ fileName: "a.ts", diffStr, changes: [{ operation, path: "a.ts" }] }).patch, + ).toBe(diffStr); + } + for (const diffStr of [ + undefined, + "", + "provider output", + "--- a/a.ts\n+++ b/a.ts\n@@ -1 +1 @@\n-a\n+b\n", + ]) { + expect( + toolCallDiff({ fileName: "a.ts", ...(diffStr === undefined ? {} : { diffStr }) }).patch, + ).toBe(diffStr); + } + }); +}); diff --git a/apps/web/src/lib/toolCallDiff.ts b/apps/web/src/lib/toolCallDiff.ts new file mode 100644 index 0000000000000..2224702601d74 --- /dev/null +++ b/apps/web/src/lib/toolCallDiff.ts @@ -0,0 +1,77 @@ +import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; +import { getRenderablePatch, resolveFileDiffPath } from "./diffRendering"; + +type FileChange = Extract; + +function toolCallPatch(item: Pick) { + let patch = item.diffStr; + if (!patch) return patch; + const change = item.changes?.[0]; + const rawFileOperation = change?.operation === "add" || change?.operation === "delete"; + if (patch.startsWith("diff --git ") || patch.startsWith("--- ")) { + if (!rawFileOperation) return patch; + const parsed = getRenderablePatch(patch, "tool-call-format"); + if ( + parsed?.kind === "files" && + parsed.files.every((file) => { + const path = resolveFileDiffPath(file).replaceAll("\\", "/"); + return ( + file.hunks.length > 0 && + item.changes?.some((recorded) => { + const recordedPath = recorded.path.replaceAll("\\", "/"); + return ( + (recorded.operation === "add" + ? file.deletionLines.length === 0 + : recorded.operation === "delete" + ? file.additionLines.length === 0 + : true) && + (recordedPath === path || recordedPath.endsWith(`/${path}`)) + ); + }) + ); + }) + ) + return patch; + } + // Codex can record added/deleted file contents or bare hunks instead of a complete patch. + if (rawFileOperation || !patch.trimStart().startsWith("@@ ")) { + if (!rawFileOperation) return patch; + const lines = patch.split("\n"); + const hasFinalNewline = patch.endsWith("\n"); + if (hasFinalNewline) lines.pop(); + const added = change.operation === "add"; + const range = `1,${lines.length}`; + patch = `@@ -${added ? "0,0" : range} +${added ? range : "0,0"} @@\n${lines.map((line) => `${added ? "+" : "-"}${line}`).join("\n")}\n${hasFinalNewline ? "" : "\\ No newline at end of file\n"}`; + } + const oldPath = change?.oldPath ?? item.fileName; + const newPath = change?.path ?? item.fileName; + const before = change?.operation === "add" ? "/dev/null" : JSON.stringify(`a/${oldPath}`); + const after = change?.operation === "delete" ? "/dev/null" : JSON.stringify(`b/${newPath}`); + return `diff --git ${JSON.stringify(`a/${oldPath}`)} ${JSON.stringify(`b/${newPath}`)}\n--- ${before}\n+++ ${after}\n${patch.trimStart()}`; +} + +function recordedText(value: string | undefined) { + const suffix = "\n… output truncated for transport"; + const truncated = value !== undefined && value.endsWith(suffix); + return { text: truncated ? value.slice(0, -suffix.length) : value, truncated }; +} + +export function toolCallDiff( + item: Pick, +) { + const diff = recordedText(item.diffStr); + const operation = item.changes?.[0]?.operation; + const emptyContent = diff.text === "" ? "" : undefined; + const before = recordedText(item.oldStr ?? (operation === "delete" ? emptyContent : undefined)); + const after = recordedText(item.newStr ?? (operation === "add" ? emptyContent : undefined)); + return { + patch: toolCallPatch({ ...item, ...(diff.text === undefined ? {} : { diffStr: diff.text }) }), + content: diff.text?.trim() + ? [] + : [ + { label: "Recorded content before", text: before.text }, + { label: "Recorded content after", text: after.text }, + ].filter((entry): entry is { label: string; text: string } => entry.text !== undefined), + truncated: diff.truncated || before.truncated || after.truncated, + }; +} diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index ead4f553fc43d..7e7f43fa632cc 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -838,7 +838,24 @@ export function acpToolCallDiffPatch(content: unknown): string | undefined { }); for (const diff of diffs) { const patch = unknownRecord(diff.patch); - if (typeof patch?.text === "string") return patch.text; + if (typeof patch?.text === "string") { + const change = + Array.isArray(diff.changes) && diff.changes.length === 1 + ? unknownRecord(diff.changes[0]) + : undefined; + if ( + patch.format === "git_patch" && + patch.text.trimStart().startsWith("@@ ") && + typeof change?.path === "string" + ) { + const oldPath = typeof change.oldPath === "string" ? change.oldPath : change.path; + const before = change.operation === "add" ? "/dev/null" : JSON.stringify(`a/${oldPath}`); + const after = + change.operation === "delete" ? "/dev/null" : JSON.stringify(`b/${change.path}`); + return `diff --git ${JSON.stringify(`a/${oldPath}`)} ${JSON.stringify(`b/${change.path}`)}\n--- ${before}\n+++ ${after}\n${patch.text.trimStart()}`; + } + return patch.text; + } } const v1Patches = diffs.flatMap((diff) => { if (typeof diff.path !== "string" || typeof diff.newText !== "string") return []; From cfc8be97d733caa089ecd26198d1be9d56669126 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 00:51:34 -0700 Subject: [PATCH 201/317] ci: a stalled Node.js download retries after 2 minutes, not 10 (#26343) Co-authored-by: Claude Opus 5.5 --- .github/actions/setup-ci-deps/action.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index afaef17e1c310..e97365e2f3ec5 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -47,9 +47,14 @@ runs: echo "$HOME/.vite-plus/bin" >> "$GITHUB_PATH" "$HOME/.vite-plus/bin/vp" env use "${{ steps.vp.outputs.node }}" + # vp gives each Node.js download attempt 10 minutes by default, so one + # stalled stream holds the job that long. It retries 3 times and resumes + # partial bytes, so a shorter limit costs a slow download only a retry. - name: Setup Vite+ if: steps.vp_cache.outputs.cache-hit != 'true' uses: voidzero-dev/setup-vp@250f29ce396baf5e8f24498e17c0dfdebabc26eb # v1 + env: + VP_DOWNLOAD_TIMEOUT: "120" with: node-version-file: package.json cache: false From c3f5e363e59371d292f4a7e27008d2c5d1260725 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 02:52:00 -0500 Subject: [PATCH 202/317] fix(server): avoid replaying stale Codex usage limits (#26331) --- .../Adapters/CodexAdapterV2.ts | 4 ++-- .../src/provider/codexUsageLimits.test.ts | 23 +++++++++++++++++++ apps/server/src/provider/codexUsageLimits.ts | 14 ++++++++++- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 36702689be9e8..e372b1b9466b0 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -4304,7 +4304,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( yield* client.handleServerNotification("account/rateLimits/updated", (payload) => Effect.gen(function* () { - yield* Ref.update(rateLimitSnapshot, (previous) => + const previous = yield* Ref.getAndUpdate(rateLimitSnapshot, (previous) => mergeCodexRateLimits(previous, payload.rateLimits), ); const resetAt = codexUsageLimitResetAt(yield* Ref.get(rateLimitSnapshot)); @@ -4324,7 +4324,7 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( turnItem: updated, }); } - const update = codexRateLimitsToUpdate(payload.rateLimits); + const update = codexRateLimitsToUpdate(payload.rateLimits, previous); if (update && adapterOptions.onUsageLimits) { const checkedAt = DateTime.formatIso(yield* DateTime.now); yield* adapterOptions.onUsageLimits({ ...update, checkedAt }); diff --git a/apps/server/src/provider/codexUsageLimits.test.ts b/apps/server/src/provider/codexUsageLimits.test.ts index 934412bd3bb41..9c863f54c0867 100644 --- a/apps/server/src/provider/codexUsageLimits.test.ts +++ b/apps/server/src/provider/codexUsageLimits.test.ts @@ -128,6 +128,29 @@ describe("codexRateLimitsToLimits", () => { }); describe("codexRateLimitsToUpdate", () => { + it("does not republish cached windows over a newer account probe", () => { + const cached = { + primary: { usedPercent: 0, resetsAt: 1_784_000_000, windowDurationMins: 300 }, + secondary: { usedPercent: 20, resetsAt: 1_784_500_000, windowDurationMins: 10080 }, + }; + expect(codexRateLimitsToUpdate(cached)?.windows).toHaveLength(2); + expect(codexRateLimitsToUpdate(structuredClone(cached), cached)).toBeUndefined(); + + const changed = { ...cached, secondary: { ...cached.secondary, usedPercent: 21 } }; + expect(codexRateLimitsToUpdate(changed, cached)?.windows.map((window) => window.id)).toEqual([ + "secondary", + ]); + expect(codexRateLimitsToUpdate({ primary: cached.primary }, cached)).toBeUndefined(); + expect( + codexRateLimitsToUpdate({ primary: { ...cached.primary, resetsAt: 1_784_001_000 } }, cached) + ?.windows, + ).toHaveLength(1); + expect( + codexRateLimitsToUpdate({ primary: { ...cached.primary, usedPercent: 76 } }, cached) + ?.windows[0]?.usedPercent, + ).toBe(76); + }); + it("carries only the windows the notification names", () => { expect( codexRateLimitsToUpdate({ diff --git a/apps/server/src/provider/codexUsageLimits.ts b/apps/server/src/provider/codexUsageLimits.ts index f8718cdda03d9..d4f9c621119df 100644 --- a/apps/server/src/provider/codexUsageLimits.ts +++ b/apps/server/src/provider/codexUsageLimits.ts @@ -135,8 +135,20 @@ export function codexRateLimitsToLimits(input: { export function codexRateLimitsToUpdate( snapshot: CodexRateLimitSnapshot, + previous?: CodexRateLimitSnapshot, ): ProviderUsageLimitsUpdate | undefined { - const windows = codexRateLimitsToWindows(snapshot); + const previousWindows = previous ? codexRateLimitsToWindows(previous) : []; + // Token ticks repeat session-cached limits that may predate an account probe or reset. + const windows = codexRateLimitsToWindows(snapshot).filter((window) => { + const existing = previousWindows.find((candidate) => candidate.id === window.id); + return ( + !existing || + existing.kind !== window.kind || + existing.usedPercent !== window.usedPercent || + existing.resetsAt !== window.resetsAt || + existing.windowDurationMins !== window.windowDurationMins + ); + }); return windows.length > 0 ? { windows } : undefined; } From ff424802edd1560fd99fe163e678b67ec09f69b3 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 02:56:27 -0500 Subject: [PATCH 203/317] feat(web): mark PR files viewed with shift-click (#26349) --- .../pullRequest/PullRequestCodeTab.tsx | 29 +++++++++++++++---- .../pullRequest/pullRequestDiff.logic.test.ts | 17 +++++++++++ .../pullRequest/pullRequestDiff.logic.ts | 12 ++++++++ 3 files changed, 53 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx index f8d58cfaa020d..d1b165c62147b 100644 --- a/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestCodeTab.tsx @@ -106,6 +106,7 @@ import { toViewerSide, } from "./PullRequestReviewAnnotation"; import { + getFileViewedRange, isFileDiffCollapsed, isLineInFileDiff, toggleFileDiffFoldForViewed, @@ -175,7 +176,7 @@ interface FileViewedToggleState { readonly enabled: boolean; readonly isViewed: (path: string) => boolean; readonly isStale: (path: string) => boolean; - readonly setFileViewed: (fileKey: string, path: string, viewed: boolean) => void; + readonly setFileViewed: (fileKey: string, viewed: boolean, extendRange: boolean) => void; } // Context updates viewed toggles without invalidating memoized header portals. @@ -205,7 +206,13 @@ function FileViewedToggle({ viewedFiles.setFileViewed(fileKey, path, next === true)} + onCheckedChange={(next, details) => + viewedFiles.setFileViewed( + fileKey, + next === true, + "shiftKey" in details.event && details.event.shiftKey === true, + ) + } /> {stale ? ( @@ -287,6 +294,7 @@ function PullRequestCodeTab({ wordWrap: settings.wordWrap, })); const [toggledFiles, setToggledFiles] = useState>(() => new Set()); + const [lastViewedFileKey, setLastViewedFileKey] = useState(null); const [visibleCommitCount, setVisibleCommitCount] = useState(COMMIT_PAGE_SIZE); const [foldOverride, setFoldOverride] = useState(null); const effectiveFoldOverride = @@ -326,6 +334,7 @@ function PullRequestCodeTab({ const [stateScopeKey, setStateScopeKey] = useState(scopeKey); if (stateScopeKey !== scopeKey) { setStateScopeKey(scopeKey); + setLastViewedFileKey(null); setDraft(null); setSelectedLines(null); setToggledFiles(new Set()); @@ -689,10 +698,20 @@ function PullRequestCodeTab({ return next; }); - const setFileViewed = (fileKey: string, path: string, viewed: boolean) => { - setViewed(path, viewed); + const setFileViewed = (fileKey: string, viewed: boolean, extendRange: boolean) => { + const range = getFileViewedRange( + annotatedFiles, + fileKey, + extendRange ? lastViewedFileKey : null, + ); + setLastViewedFileKey(fileKey); + for (const file of range) setViewed(file.path, viewed); setToggledFiles((current) => - toggleFileDiffFoldForViewed(fileKey, viewed, effectiveFoldOverride, current), + range.reduce( + (next, file) => + toggleFileDiffFoldForViewed(file.fileKey, viewed, effectiveFoldOverride, next), + current, + ), ); }; const fileViewedToggle = { diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts index 4e11e04e1d784..552fa748f07f6 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.test.ts @@ -2,6 +2,7 @@ import type { FileDiffMetadata } from "@pierre/diffs"; import { describe, expect, it } from "vite-plus/test"; import { + getFileViewedRange, isFileDiffCollapsed, isLineInFileDiff, toggleFileDiffFoldForViewed, @@ -54,3 +55,19 @@ describe("toggleFileDiffFoldForViewed", () => { expect(toggleFileDiffFoldForViewed("a.ts", false, "expanded", new Set()).size).toBe(0); }); }); + +describe("getFileViewedRange", () => { + const files = ["c.ts", "a.ts", "b.ts", "d.ts"].map((fileKey) => ({ fileKey })); + + it("includes both endpoints in displayed order in either direction", () => { + expect(getFileViewedRange(files, "b.ts", "c.ts")).toEqual(files.slice(0, 3)); + expect(getFileViewedRange(files, "c.ts", "b.ts")).toEqual(files.slice(0, 3)); + expect(getFileViewedRange(files, "a.ts", "a.ts")).toEqual([files[1]]); + }); + + it("changes only the clicked file without a current anchor", () => { + expect(getFileViewedRange(files, "a.ts", null)).toEqual([files[1]]); + expect(getFileViewedRange(files, "a.ts", "removed.ts")).toEqual([files[1]]); + expect(getFileViewedRange(files, "removed.ts", "a.ts")).toEqual([]); + }); +}); diff --git a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts index 37fbfe30e9ab4..f175c0151431a 100644 --- a/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDiff.logic.ts @@ -39,3 +39,15 @@ export function toggleFileDiffFoldForViewed( else next.add(fileKey); return next; } + +export function getFileViewedRange( + files: ReadonlyArray, + fileKey: string, + anchorFileKey: string | null, +): ReadonlyArray { + const index = files.findIndex((file) => file.fileKey === fileKey); + if (index === -1) return []; + const anchorIndex = files.findIndex((file) => file.fileKey === anchorFileKey); + if (anchorIndex === -1) return files.slice(index, index + 1); + return files.slice(Math.min(index, anchorIndex), Math.max(index, anchorIndex) + 1); +} From 9bc4138923481dcada7f90d97ecaf0c42184bd4f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 00:56:41 -0700 Subject: [PATCH 204/317] refactor: flatten nested call chains (#26350) Co-authored-by: Claude Opus 5.5 --- apps/desktop/src/app/DesktopAppActivation.ts | 7 +- apps/desktop/src/snapShot/DesktopSnapShot.ts | 9 +- .../connection/ConnectionDiagnosisDetails.tsx | 5 +- .../usePullRequestFilesViewed.ts | 6 +- .../pull-requests/usePullRequestList.ts | 3 +- .../settings/EnvironmentRoutesSection.tsx | 2 +- .../settings/environmentUpdateCheck.ts | 2 +- .../terminal/ThreadTerminalRouteScreen.tsx | 3 +- .../src/features/threads/LinkRequestCard.tsx | 5 +- .../features/threads/SecretRequestCard.tsx | 3 +- .../src/features/triage/useStartTriage.ts | 17 ++- apps/mobile/src/state/entities.ts | 3 +- apps/mobile/src/state/mediaActions.ts | 3 +- apps/mobile/src/state/projectClones.ts | 3 +- apps/mobile/src/state/queries.ts | 2 +- apps/mobile/src/state/query.ts | 3 +- apps/mobile/src/state/session.ts | 2 +- apps/mobile/src/state/threads.ts | 5 +- apps/mobile/src/state/usage.ts | 5 +- apps/mobile/src/state/use-composer-drafts.ts | 4 +- apps/server/scripts/fetch-expo-schema.ts | 7 +- .../scripts/record-pi-rpc-replay-fixture.ts | 9 +- apps/server/src/http.ts | 9 +- .../Adapters/CodexAdapterV2.ts | 8 +- apps/server/src/provider/CodexChatGptAuth.ts | 133 ++++++++---------- apps/server/src/provider/CodexProvider.ts | 10 +- .../Drivers/antigravityUsageReader.ts | 5 +- apps/server/src/secrets/SecretRequests.ts | 5 +- apps/server/src/ws.ts | 18 ++- .../src/browser/previewWebviewConfigState.ts | 3 +- apps/web/src/cloud/primaryCloudLinkState.ts | 3 +- apps/web/src/components/ChatView.tsx | 14 +- .../src/components/ThreadStatusIndicators.tsx | 7 +- .../src/components/chat/LinkRequestCard.tsx | 5 +- .../src/components/chat/SecretRequestCard.tsx | 3 +- .../components/chat/ThreadDetailsPrRows.tsx | 3 +- .../chat/useComposerProviderModel.ts | 14 +- .../components/device/DeviceStreamView.tsx | 3 +- .../files/extendedDocumentPreview.ts | 5 +- apps/web/src/components/files/office/fonts.ts | 5 +- .../src/components/files/office/slide-text.ts | 95 +++++++------ .../files/projectFilesQueryState.ts | 4 +- .../web/src/components/media/MediaActions.tsx | 3 +- .../components/preview/agentOpenedPreviews.ts | 3 +- .../pullRequest/filesViewedStore.ts | 3 +- .../guide/PullRequestGuideRunCoordinator.tsx | 3 +- .../components/settings/AcpRegistryIcon.tsx | 8 +- .../settings/AcpRegistrySearchStep.tsx | 3 +- .../settings/AddEnvironmentDialog.tsx | 3 +- .../settings/ConnectionDiagnosisDetails.tsx | 4 +- .../settings/ProjectDefaultsSettings.tsx | 12 +- .../settings/ScheduledTasksSettings.tsx | 19 +-- .../components/settings/SettingsPanels.tsx | 16 +-- .../settings/SettingsScopeContext.tsx | 3 +- .../settings/SourceControlWritingSettings.tsx | 16 +-- .../components/usage/UsagePriceOverrides.tsx | 3 +- apps/web/src/composerDraftStore.ts | 4 +- .../src/environments/primary/sessionState.ts | 3 +- apps/web/src/filePathDisplay.ts | 7 +- apps/web/src/hooks/useSettings.ts | 2 +- apps/web/src/hooks/useTheme.ts | 3 +- .../src/lib/assistantCitationNavigation.ts | 8 +- apps/web/src/onboarding/useProjectScans.ts | 3 +- apps/web/src/providerInstances.ts | 16 ++- apps/web/src/state/projectClones.ts | 3 +- apps/web/src/state/pullRequests.ts | 4 +- apps/web/src/state/queries.ts | 3 +- apps/web/src/state/query.ts | 2 +- apps/web/src/state/server.ts | 16 ++- apps/web/src/state/session.ts | 4 +- apps/web/src/state/shell.ts | 4 +- apps/web/src/state/sourceControlActions.ts | 16 +-- apps/web/src/state/terminalSessions.ts | 7 +- apps/web/src/state/threads.ts | 2 +- apps/web/src/state/usage.ts | 5 +- apps/web/src/state/waitForQuerySettled.ts | 3 +- apps/web/src/themePalette.ts | 14 +- .../src/relay/managedRelayState.ts | 3 +- .../src/state/archivedThreads.ts | 3 +- packages/client-runtime/src/state/assets.ts | 2 +- .../src/state/commandPermissions.ts | 2 +- .../client-runtime/src/state/connections.ts | 7 +- .../src/state/olderSettledThreads.test.ts | 2 +- .../src/state/olderSettledThreads.ts | 2 +- .../client-runtime/src/state/presentation.ts | 4 +- .../src/state/pullRequests.test.ts | 10 +- .../client-runtime/src/state/pullRequests.ts | 4 +- .../src/state/relayDiscovery.ts | 5 +- .../client-runtime/src/state/runtime.test.ts | 2 +- packages/client-runtime/src/state/runtime.ts | 9 +- packages/client-runtime/src/state/server.ts | 5 +- packages/client-runtime/src/state/session.ts | 14 +- packages/client-runtime/src/state/shell.ts | 2 +- packages/client-runtime/src/state/terminal.ts | 3 +- .../client-runtime/src/state/threadDetail.ts | 4 +- .../src/state/threadFamilies.ts | 2 +- .../client-runtime/src/state/threadSearch.ts | 2 +- .../scripts/generate.ts | 33 ++--- .../effect-codex-app-server/src/replay.ts | 7 +- .../provider-core/src/server/handoffBudget.ts | 6 +- packages/shared/src/dpop.ts | 8 +- scripts/lib/public-config.ts | 8 +- 102 files changed, 423 insertions(+), 414 deletions(-) diff --git a/apps/desktop/src/app/DesktopAppActivation.ts b/apps/desktop/src/app/DesktopAppActivation.ts index 340cb23136441..094033192e088 100644 --- a/apps/desktop/src/app/DesktopAppActivation.ts +++ b/apps/desktop/src/app/DesktopAppActivation.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off -- Local socket ownership checks need lstat, an atomic rename, and a directory watch at the Node adapter boundary. +import { pipe } from "effect/Function"; import * as NodeCrypto from "node:crypto"; import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; @@ -151,8 +152,10 @@ export async function startDesktopAppControlServer(input: { } if (!isDesktopAppActivationRequest(parsed)) { - finish( - invalidResponse(requestIdFromUnknown(parsed), "The desktop app request is invalid."), + pipe( + requestIdFromUnknown(parsed), + (requestId) => invalidResponse(requestId, "The desktop app request is invalid."), + finish, ); return; } diff --git a/apps/desktop/src/snapShot/DesktopSnapShot.ts b/apps/desktop/src/snapShot/DesktopSnapShot.ts index 76daee09c2aeb..e31d347084209 100644 --- a/apps/desktop/src/snapShot/DesktopSnapShot.ts +++ b/apps/desktop/src/snapShot/DesktopSnapShot.ts @@ -1,5 +1,6 @@ // @effect-diagnostics globalTimers:off -- Capture timeouts and Electron overlay animation timers run at native callback boundaries outside Effect fibers. +import { pipe } from "effect/Function"; import { DEFAULT_CLIENT_SETTINGS, DesktopPendingSnapShot, @@ -1288,10 +1289,10 @@ export const make = Effect.gen(function* () { if (isModifierPairShortcut(shortcut)) { registered = yield* Effect.tryPromise(() => startPairShortcutProcess(snapShotShortcutModifierPair(shortcut), onCurrentShortcut, () => { - void runPromise( - setShortcutFailure( - snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), - ), + void pipe( + snapShotShortcutRegistrationFailureMessage(shortcut, environment.platform), + setShortcutFailure, + runPromise, ).catch(() => undefined); }), ).pipe( diff --git a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx index df0e8c165929e..b3922c4a95eda 100644 --- a/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx +++ b/apps/mobile/src/features/connection/ConnectionDiagnosisDetails.tsx @@ -10,7 +10,6 @@ import { } from "@t3tools/client-runtime/connection"; import type { EnvironmentId } from "@t3tools/contracts"; import Constants from "expo-constants"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { Linking, View } from "react-native"; @@ -28,8 +27,8 @@ export function useConnectionDiagnosis( environmentId: EnvironmentId, presentation: EnvironmentPresentation | null, ) { - const state = Option.getOrElse( - AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + useAtomValue(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); if (presentation === null || !presentation.entry.enabled) return { diagnosis: null, state }; diff --git a/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts b/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts index 46f6f941806a7..7467c73fbe403 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestFilesViewed.ts @@ -2,7 +2,6 @@ import { usePullRequestConnected } from "./usePullRequestConnected"; import { RegistryContext, useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import type { PullRequestDetail } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { use, useState } from "react"; import { composerPullRequests } from "../../state/pull-requests"; @@ -50,8 +49,9 @@ export function usePullRequestFilesViewed(params: PullRequestParams, pr: PullReq input: { ...target.input, files: [{ path, viewed }] }, }); // The read that is current once the write lands still predates it; a newer one carries it. - const readAtWrite = Option.getOrNull( - AsyncResult.value(registry.get(composerPullRequests.filesViewed(target))), + const readAtWrite = AsyncResult.getOrElse( + registry.get(composerPullRequests.filesViewed(target)), + () => null, ); setState((current) => { if (current.key !== scopeKey || current.presses[path]?.version !== version) return current; diff --git a/apps/mobile/src/features/pull-requests/usePullRequestList.ts b/apps/mobile/src/features/pull-requests/usePullRequestList.ts index a87e07b4dbff8..5bc3cf730b520 100644 --- a/apps/mobile/src/features/pull-requests/usePullRequestList.ts +++ b/apps/mobile/src/features/pull-requests/usePullRequestList.ts @@ -3,7 +3,6 @@ import { executeAtomQuery, squashAtomCommandFailure } from "@t3tools/client-runt import type { EnvironmentId, PullRequestDiffStat, PullRequestListInput } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; import { use, useMemo, useState } from "react"; import { environmentCatalog } from "../../connection/catalog"; @@ -32,7 +31,7 @@ type Read = (atom: Atom.Atom) => A; /** The head the list query holds now, cached or answered. */ function currentHead(read: Read, target: ListTarget) { - return Option.getOrNull(AsyncResult.value(read(composerPullRequests.list(target)))); + return AsyncResult.getOrElse(read(composerPullRequests.list(target)), () => null); } function currentPages(read: Read, target: ListTarget): PullRequestListPages | null { diff --git a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx index 632fb08c2e7b3..012d00737d1df 100644 --- a/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx +++ b/apps/mobile/src/features/settings/EnvironmentRoutesSection.tsx @@ -56,7 +56,7 @@ export function EnvironmentRoutesSection({ const entry = useAtomValue(environmentCatalog.catalogValueAtom).entries.get(environmentId); const prepared = useAtomValue(environmentSession.preparedConnectionValueAtom(environmentId)); const sessionResult = useAtomValue(environmentSession.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(sessionResult)); + const session = AsyncResult.getOrElse(sessionResult, () => null); const [permissionsOpen, setPermissionsOpen] = useState(false); const reorder = useAtomCommand(environmentCatalog.reorderRoutes, "route reorder"); const removeRoute = useAtomCommand(environmentCatalog.removeRoute, "route removal"); diff --git a/apps/mobile/src/features/settings/environmentUpdateCheck.ts b/apps/mobile/src/features/settings/environmentUpdateCheck.ts index b75bc06a3949a..1bbe0c63cae5d 100644 --- a/apps/mobile/src/features/settings/environmentUpdateCheck.ts +++ b/apps/mobile/src/features/settings/environmentUpdateCheck.ts @@ -40,7 +40,7 @@ export const environmentReleaseCheckAtom = Atom.family((environmentId: Environme export function releaseCheckValue( result: AsyncResult.AsyncResult, ): EnvironmentReleaseCheck | null { - return Option.getOrNull(AsyncResult.value(result)); + return AsyncResult.getOrElse(result, () => null); } export function releaseCheckError( diff --git a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx index 2f6cc0d292a7f..a2a2450864eee 100644 --- a/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx +++ b/apps/mobile/src/features/terminal/ThreadTerminalRouteScreen.tsx @@ -20,7 +20,6 @@ import { AppText as Text } from "../../components/ui/AppText"; import { TerminalContextSheet } from "./TerminalContextSheet"; import { hasNativeTerminalSurface, supportsNativeTerminalOutput } from "./nativeTerminalModule"; import * as Clipboard from "expo-clipboard"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { @@ -340,7 +339,7 @@ function readLiveTerminalSessions( ): ReadonlyArray { if (!readEnvironmentScope(environmentId, AuthTerminalReadScope)) return []; const result = appAtomRegistry.get(terminalEnvironment.metadata({ environmentId, input: null })); - const summaries = Option.getOrElse(AsyncResult.value(result), () => []); + const summaries = AsyncResult.getOrElse(result, () => []); return summaries .filter((summary) => summary.threadId === threadId) .map((summary) => ({ diff --git a/apps/mobile/src/features/threads/LinkRequestCard.tsx b/apps/mobile/src/features/threads/LinkRequestCard.tsx index 2bbb168ba5b32..ac96063001836 100644 --- a/apps/mobile/src/features/threads/LinkRequestCard.tsx +++ b/apps/mobile/src/features/threads/LinkRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { useAtomValue } from "@effect/atom-react"; import { type LinkableMachine, @@ -125,7 +126,7 @@ function PendingLinkRequest(props: { setDeclining(false), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; @@ -311,7 +312,7 @@ function LinkRequestSheet(props: { setPairingCode(""); props.onClose(); } else if (!isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; diff --git a/apps/mobile/src/features/threads/SecretRequestCard.tsx b/apps/mobile/src/features/threads/SecretRequestCard.tsx index 0112257fec713..4ab7a18beffec 100644 --- a/apps/mobile/src/features/threads/SecretRequestCard.tsx +++ b/apps/mobile/src/features/threads/SecretRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, secretRequestAnswerInput, @@ -91,7 +92,7 @@ function PendingSecretRequestForm(props: { return; } if (!isAtomCommandInterrupted(result)) { - setError(secretRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), secretRequestFailureMessage, setError); } }; diff --git a/apps/mobile/src/features/triage/useStartTriage.ts b/apps/mobile/src/features/triage/useStartTriage.ts index 059e852337d22..eefb775bcd177 100644 --- a/apps/mobile/src/features/triage/useStartTriage.ts +++ b/apps/mobile/src/features/triage/useStartTriage.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -67,11 +68,11 @@ export function useStartTriage() { const scratch = await openScratch({ environmentId, input: {} }); if (scratch._tag === "Failure") { if (!isAtomCommandInterrupted(scratch)) { - fail( - errorMessage( - squashAtomCommandFailure(scratch), - "This machine cannot start threads without a project.", - ), + pipe( + squashAtomCommandFailure(scratch), + (failure) => + errorMessage(failure, "This machine cannot start threads without a project."), + fail, ); } return; @@ -82,7 +83,11 @@ export function useStartTriage() { }); if (prepared._tag === "Failure") { if (!isAtomCommandInterrupted(prepared)) { - fail(errorMessage(squashAtomCommandFailure(prepared), "The server could not prepare it.")); + pipe( + squashAtomCommandFailure(prepared), + (failure) => errorMessage(failure, "The server could not prepare it."), + fail, + ); } return; } diff --git a/apps/mobile/src/state/entities.ts b/apps/mobile/src/state/entities.ts index c98c8a11488cf..a376938ffb41f 100644 --- a/apps/mobile/src/state/entities.ts +++ b/apps/mobile/src/state/entities.ts @@ -25,7 +25,6 @@ import { threadLinkRefs, type ThreadLink, } from "@t3tools/shared/threadLinks"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -117,7 +116,7 @@ const threadProjectChipIconAtom = Atom.family((key: string) => { ); if (!icon) return null; if (icon.kind === "favicon") return icon.uri; - return Option.getOrNull(AsyncResult.value(get(projectGlyphImageAtom(projectGlyphKey(icon))))); + return AsyncResult.getOrElse(get(projectGlyphImageAtom(projectGlyphKey(icon))), () => null); }).pipe(Atom.withLabel(`mobile-thread-project-chip-icon:${key}`)); }); diff --git a/apps/mobile/src/state/mediaActions.ts b/apps/mobile/src/state/mediaActions.ts index b8736d6d3c36e..22a3a9a26bb69 100644 --- a/apps/mobile/src/state/mediaActions.ts +++ b/apps/mobile/src/state/mediaActions.ts @@ -13,7 +13,6 @@ import { type SessionGrantInput, } from "@t3tools/contracts"; import { normalizeNativeMarkdownUrl } from "../lib/markdownLinks"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { useEffect, useRef, useState } from "react"; @@ -32,7 +31,7 @@ function allowsHostMedia(session: SessionGrantInput | null) { function canReadHostMedia(environmentId: EnvironmentId | null): boolean { if (environmentId === null) return true; const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); - return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); + return result._tag !== "Failure" && allowsHostMedia(AsyncResult.getOrElse(result, () => null)); } export function useMediaActions(source: MediaActionsSource | undefined, onOpenFile?: () => void) { diff --git a/apps/mobile/src/state/projectClones.ts b/apps/mobile/src/state/projectClones.ts index 321cff9db2202..fc8176a8aff92 100644 --- a/apps/mobile/src/state/projectClones.ts +++ b/apps/mobile/src/state/projectClones.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { serverEnvironment } from "./server"; @@ -22,7 +21,7 @@ const environmentProjectClonesAtom = Atom.family((environmentId: EnvironmentId) const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); // On subscription failure, let server dispatch guards decide rather than blocking Start until reconnect. if (result._tag === "Failure") return EMPTY_CLONES; - return Option.getOrElse(AsyncResult.value(result), () => "pending" as const); + return AsyncResult.getOrElse(result, () => "pending" as const); }).pipe(Atom.withLabel(`mobile-project-clones:${environmentId}`)), ); diff --git a/apps/mobile/src/state/queries.ts b/apps/mobile/src/state/queries.ts index f923314b4cb72..75e60887546d3 100644 --- a/apps/mobile/src/state/queries.ts +++ b/apps/mobile/src/state/queries.ts @@ -318,7 +318,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { ); const results = useAtomValue(pagesAtom); const values = results.flatMap((result) => { - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); return value === null ? [] : [value]; }); const refs = new Map(); diff --git a/apps/mobile/src/state/query.ts b/apps/mobile/src/state/query.ts index b5d5b6a6a1a66..8c3a75fb3bf45 100644 --- a/apps/mobile/src/state/query.ts +++ b/apps/mobile/src/state/query.ts @@ -1,6 +1,5 @@ import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; const EMPTY_ASYNC_RESULT_ATOM = Atom.make(AsyncResult.initial(false)).pipe( @@ -31,7 +30,7 @@ export function useEnvironmentQuery( const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), // Data may be structurally shared; only a settled success proves a new response. successResult: result._tag === "Success" && !result.waiting ? result : null, failure: result._tag === "Failure" ? Cause.squash(result.cause) : null, diff --git a/apps/mobile/src/state/session.ts b/apps/mobile/src/state/session.ts index 010325dfe722a..c7fbecab5805d 100644 --- a/apps/mobile/src/state/session.ts +++ b/apps/mobile/src/state/session.ts @@ -21,7 +21,7 @@ export function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { - const session = Option.getOrNull(AsyncResult.value(result)); + const session = AsyncResult.getOrElse(result, () => null); return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } diff --git a/apps/mobile/src/state/threads.ts b/apps/mobile/src/state/threads.ts index 28cb2f88c0d61..4cb0f92b4ee83 100644 --- a/apps/mobile/src/state/threads.ts +++ b/apps/mobile/src/state/threads.ts @@ -10,7 +10,6 @@ import { createThreadFamilyAtoms, } from "@t3tools/client-runtime/state/threads"; import type { EnvironmentId, ThreadId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect } from "react"; @@ -94,8 +93,8 @@ export function useEnvironmentThread( ? environmentThreads.stateAtom(environmentId, threadId) : EMPTY_THREAD_STATE_ATOM, ); - const state = Option.getOrElse( - AsyncResult.value(result), + const state = AsyncResult.getOrElse( + result, () => EMPTY_ENVIRONMENT_THREAD_STATE, ) as EnvironmentThreadState; return state; diff --git a/apps/mobile/src/state/usage.ts b/apps/mobile/src/state/usage.ts index b0931e175c558..9851b841c88c5 100644 --- a/apps/mobile/src/state/usage.ts +++ b/apps/mobile/src/state/usage.ts @@ -9,7 +9,6 @@ import { import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; import { mergeUsage, type EnvironmentUsage, type MergedUsage } from "@t3tools/shared/usageMerge"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useCallback, useMemo } from "react"; @@ -39,7 +38,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); const access = resolveUsageAccess({ connectionPhase: presentation.connection.phase, - session: Option.getOrNull(AsyncResult.value(sessionResult)), + session: AsyncResult.getOrElse(sessionResult, () => null), hasSessionError: sessionResult._tag === "Failure", }); if (!access.canReadDiagnostics) { @@ -54,7 +53,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => continue; } const result = get(serverEnvironment.usageSummary({ environmentId, input })); - const summary = Option.getOrNull(AsyncResult.value(result)); + const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, label: presentation.entry.target.label, diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 8c049cd4b6180..791383be9e7a3 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -1517,9 +1517,7 @@ export function subscribeComposerDraftModelReconciliation(input: { if (!thread || !draftModelPickMatchesThread(draft.modelSelection, thread.modelSelection)) continue; // Status and selection must come from the same emission; derived atoms can update apart. - const shell = Option.getOrNull( - AsyncResult.value(get(input.shellStateAtom(ref.environmentId))), - ); + const shell = AsyncResult.getOrElse(get(input.shellStateAtom(ref.environmentId)), () => null); if (shell?.status !== "live") continue; const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( (candidate) => candidate.id === ref.threadId, diff --git a/apps/server/scripts/fetch-expo-schema.ts b/apps/server/scripts/fetch-expo-schema.ts index 9cf7c3920379f..cc41f4ffe7f98 100644 --- a/apps/server/scripts/fetch-expo-schema.ts +++ b/apps/server/scripts/fetch-expo-schema.ts @@ -4,6 +4,7 @@ * without a credential. eas-cli generates its own client from the same * endpoint. Run `vp run generate` afterwards to regenerate the operations. */ +import { pipe } from "effect/Function"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; @@ -36,8 +37,10 @@ const program = Effect.gen(function* () { Effect.flatMap(HttpClientResponse.schemaBodyJson(IntrospectionResponse)), ); // Sorted so a refresh diffs by what Expo changed, not by its declaration order. - const sdl = printSchema( - lexicographicSortSchema(buildClientSchema(response.data as unknown as IntrospectionQuery)), + const sdl = pipe( + buildClientSchema(response.data as unknown as IntrospectionQuery), + lexicographicSortSchema, + printSchema, ); const target = path.join(import.meta.dirname, "..", "schema", "expo.graphql"); yield* fs.makeDirectory(path.dirname(target), { recursive: true }); diff --git a/apps/server/scripts/record-pi-rpc-replay-fixture.ts b/apps/server/scripts/record-pi-rpc-replay-fixture.ts index 6e5d59cd735c7..8451074a426fc 100644 --- a/apps/server/scripts/record-pi-rpc-replay-fixture.ts +++ b/apps/server/scripts/record-pi-rpc-replay-fixture.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as NodeServices from "@effect/platform-node/NodeServices"; import type { ProviderReplayEntry, ProviderReplayTranscript } from "@t3tools/contracts"; import * as Console from "effect/Console"; @@ -99,8 +100,12 @@ function normalizeEntries(input: { .replaceAll(input.workspace, "") .replaceAll(home.length > 1 ? home : "\0", "~") .replace(ISO_TIMESTAMP_PATTERN, (iso) => - DateTime.formatIso( - DateTime.makeUnsafe(shiftTime(DateTime.toEpochMillis(DateTime.makeUnsafe(iso)))), + pipe( + DateTime.makeUnsafe(iso), + DateTime.toEpochMillis, + shiftTime, + DateTime.makeUnsafe, + DateTime.formatIso, ), ) .replace(UUID_PATTERN, (uuid) => { diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index af763b73f7055..8f9737a3efc18 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -16,7 +16,7 @@ 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 { cast } from "effect/Function"; +import { cast, pipe } from "effect/Function"; import { HttpClient, HttpClientResponse, @@ -181,8 +181,11 @@ export const assetFileResponse = Effect.fn("assetFileResponse")(function* ( if (bytes === null) { return HttpServerResponse.text("MCP app is too large to preview.", { status: 413 }); } - const body = new TextEncoder().encode( - injectMcpAppCookieBootstrap(injectMcpAppThemeBootstrap(new TextDecoder().decode(bytes))), + const body = pipe( + new TextDecoder().decode(bytes), + injectMcpAppThemeBootstrap, + injectMcpAppCookieBootstrap, + (html) => new TextEncoder().encode(html), ); headers["Content-Length"] = String(body.byteLength); return method === "HEAD" diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index e372b1b9466b0..58cd4a0c0c17b 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as DocumentText from "../../provider/DocumentText.ts"; import { revertCodexThread } from "../../provider/CodexThreadRevert.ts"; import { historyResponseItems } from "@t3tools/provider-core/server/handoffBudget"; @@ -1448,8 +1449,11 @@ function redactCodexProtocolValue(value: unknown): unknown { return value; } try { - return JSON.stringify( - redactCodexProtocolValue(boundProviderEventForLogging(JSON.parse(trimmed) as unknown)), + return pipe( + JSON.parse(trimmed) as unknown, + boundProviderEventForLogging, + redactCodexProtocolValue, + JSON.stringify, ); } catch { return value; diff --git a/apps/server/src/provider/CodexChatGptAuth.ts b/apps/server/src/provider/CodexChatGptAuth.ts index 1051f72814390..3e51b66a0e5ee 100644 --- a/apps/server/src/provider/CodexChatGptAuth.ts +++ b/apps/server/src/provider/CodexChatGptAuth.ts @@ -736,78 +736,69 @@ export const makeCodexChatGptAuth = Effect.fn("makeCodexChatGptAuth")(function* ), ) .pipe(Effect.map(Hex.encode), Effect.orDie); - const access = lock.withPermit( - withSessionLock( - Effect.uninterruptible( - Effect.gen(function* () { - const saved = yield* read; - if (Option.isNone(saved)) - return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); - let record = saved.value; - if ( - options.expectedProfileKey !== undefined && - (yield* profileKey(record)) !== options.expectedProfileKey - ) - return yield* failure("access", "The ChatGPT connection changed. Start a new session."); - if (!record.scopes.includes(REQUIRED_SCOPE)) - return yield* failure( - "sharing", - "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", - ); - const now = yield* Clock.currentTimeMillis; - if (record.expiresAt - now > 60_000) return record; - if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { - if (record.expiresAt > now) return record; - return yield* failure( - "refresh", - "ChatGPT cannot renew this connection yet. Try again shortly.", - ); - } - if (!record.refreshToken) { - yield* remove; - return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); - } - const tokens = yield* exchange( - new URLSearchParams({ - grant_type: "refresh_token", - client_id: record.clientId, - refresh_token: record.refreshToken, - resource, - }), - ).pipe( - Effect.interruptible, - Effect.timeout("20 seconds"), - Effect.mapError((error) => - error._tag === "ProviderSetupError" && error.operation === "client" - ? error - : failure( - "refresh", - "Could not renew the ChatGPT connection. Retry, or sign in again.", - ), - ), - ); - if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { - return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); - } - record = { - ...record, - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token, - expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, - earliestRefreshAt: earliest(tokens.earliest_refresh_at), - scopes: tokens.scope.split(/\s+/).filter(Boolean), - }; - yield* save(record); - if (!record.scopes.includes(REQUIRED_SCOPE)) - return yield* failure( - "sharing", - "ChatGPT token sharing is no longer enabled. Sign in again.", - ); - return record; - }), + const access = Effect.gen(function* () { + const saved = yield* read; + if (Option.isNone(saved)) + return yield* failure("access", "Sign in with ChatGPT to use managed Codex."); + let record = saved.value; + if ( + options.expectedProfileKey !== undefined && + (yield* profileKey(record)) !== options.expectedProfileKey + ) + return yield* failure("access", "The ChatGPT connection changed. Start a new session."); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "Token sharing is disabled. Sign in with ChatGPT and enable token sharing.", + ); + const now = yield* Clock.currentTimeMillis; + if (record.expiresAt - now > 60_000) return record; + if (record.earliestRefreshAt !== null && record.earliestRefreshAt > now) { + if (record.expiresAt > now) return record; + return yield* failure( + "refresh", + "ChatGPT cannot renew this connection yet. Try again shortly.", + ); + } + if (!record.refreshToken) { + yield* remove; + return yield* failure("refresh", "Your ChatGPT connection expired. Sign in again."); + } + const tokens = yield* exchange( + new URLSearchParams({ + grant_type: "refresh_token", + client_id: record.clientId, + refresh_token: record.refreshToken, + resource, + }), + ).pipe( + Effect.interruptible, + Effect.timeout("20 seconds"), + Effect.mapError((error) => + error._tag === "ProviderSetupError" && error.operation === "client" + ? error + : failure("refresh", "Could not renew the ChatGPT connection. Retry, or sign in again."), ), - ), - ); + ); + if (!tokens.refresh_token || tokens.token_type.toLowerCase() !== "bearer") { + return yield* failure("refresh", "ChatGPT returned an invalid renewal. Sign in again."); + } + record = { + ...record, + accessToken: tokens.access_token, + refreshToken: tokens.refresh_token, + expiresAt: (yield* Clock.currentTimeMillis) + tokens.expires_in * 1000, + earliestRefreshAt: earliest(tokens.earliest_refresh_at), + scopes: tokens.scope.split(/\s+/).filter(Boolean), + }; + yield* save(record); + if (!record.scopes.includes(REQUIRED_SCOPE)) + return yield* failure( + "sharing", + "ChatGPT token sharing is no longer enabled. Sign in again.", + ); + return record; + }).pipe(Effect.uninterruptible, withSessionLock, lock.withPermit); const logout = Effect.gen(function* () { const credentials = Option.getOrUndefined(yield* read); let confirmed = !credentials?.refreshToken; diff --git a/apps/server/src/provider/CodexProvider.ts b/apps/server/src/provider/CodexProvider.ts index 8458315f64de8..2b2d5501881b4 100644 --- a/apps/server/src/provider/CodexProvider.ts +++ b/apps/server/src/provider/CodexProvider.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -801,11 +802,10 @@ export const checkCodexProviderStatus = Effect.fn("checkCodexProviderStatus")(fu presentation: CODEX_PRESENTATION, enabled: codexSettings.enabled, checkedAt, - models: applyConfiguredCodexServiceTier( - applyPreferredCodexDefaultModel( - appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), - ), - snapshot.serviceTier, + models: pipe( + appendCustomCodexModels(snapshot.models ?? [], codexSettings.customModels), + applyPreferredCodexDefaultModel, + (models) => applyConfiguredCodexServiceTier(models, snapshot.serviceTier), ), skills: snapshot.skills, slashCommands: [ diff --git a/apps/server/src/provider/Drivers/antigravityUsageReader.ts b/apps/server/src/provider/Drivers/antigravityUsageReader.ts index 7d73f1221cb4b..d3035d5ae9b5f 100644 --- a/apps/server/src/provider/Drivers/antigravityUsageReader.ts +++ b/apps/server/src/provider/Drivers/antigravityUsageReader.ts @@ -1,5 +1,6 @@ // @effect-diagnostics-next-line nodeBuiltinImport:off - Effect's File.Info has no ctime, which the cache fingerprint needs. import * as NodeFSP from "node:fs/promises"; +import { pipe } from "effect/Function"; import type { UsageRecord } from "@t3tools/provider-core/server/usage"; import * as NodeSqliteClient from "@t3tools/shared/nodeSqliteClient"; @@ -244,7 +245,9 @@ const readDatabase = Effect.fn("readDatabase")(function* ( for (const row of yield* sql.unsafe<{ readonly data: unknown }>( "SELECT data FROM trajectory_metadata_blob", )) { - trajectoryTimestamp ??= yield* decode(() => timestamp(nested(fields(blob(row.data)), 2))); + trajectoryTimestamp ??= yield* decode(() => + pipe(blob(row.data), fields, (message) => nested(message, 2), timestamp), + ); } } const steps = tables.has("steps") diff --git a/apps/server/src/secrets/SecretRequests.ts b/apps/server/src/secrets/SecretRequests.ts index 7c8e961dbd2eb..9e3a7c033c220 100644 --- a/apps/server/src/secrets/SecretRequests.ts +++ b/apps/server/src/secrets/SecretRequests.ts @@ -1,6 +1,7 @@ // Answers stay in the secret store behind one-use refs; orchestration records only request status. // Consuming tools keep values out of transcripts, projections, clients and model context. // A `file` request instead moves its value to an owner-only file for the agent's own shell. +import { pipe } from "effect/Function"; import { CommandId, SecretRef, @@ -143,7 +144,7 @@ const make = Effect.gen(function* () { // Clear failed saves so the pending card can be answered again. Effect.tapError(() => input.answer.type === "save" - ? removeLogged(storeName(refFor(salt, input.threadId, item.id))) + ? pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged) : Effect.void, ), ); @@ -159,7 +160,7 @@ const make = Effect.gen(function* () { ); const card = recorded.turnItems.find((candidate) => candidate.id === item.id); if (card?.type === "secret_request" && card.secretStatus !== "saved") { - yield* removeLogged(storeName(refFor(salt, input.threadId, item.id))); + yield* pipe(refFor(salt, input.threadId, item.id), storeName, removeLogged); return yield* new SecretRequestError({ reason: "agent_stopped" }); } }, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index e35aaf1bb93cd..bea3b8a46097a 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -889,11 +889,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs projection: dedupeBodies ? dedupeSnapshotBodies(projection) : projection, }; })(); - return withLiveDedupe( - Stream.concat( - Stream.concat(rpcInitialItems([snapshotItem]), completionMarker), - eventStreamFrom(snapshotSequence), - ), + return rpcInitialItems([snapshotItem]).pipe( + Stream.concat(completionMarker), + Stream.concat(eventStreamFrom(snapshotSequence)), + withLiveDedupe, ); }); @@ -962,11 +961,10 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs if (plan.mode === "snapshot") { return yield* snapshotThenLive(); } - return withLiveDedupe( - Stream.concat( - Stream.concat(rpcInitialItems(replay), completionMarker), - eventStreamFrom(highWater), - ), + return rpcInitialItems(replay).pipe( + Stream.concat(completionMarker), + Stream.concat(eventStreamFrom(highWater)), + withLiveDedupe, ); } diff --git a/apps/web/src/browser/previewWebviewConfigState.ts b/apps/web/src/browser/previewWebviewConfigState.ts index 60a60525f0350..7d1042e612629 100644 --- a/apps/web/src/browser/previewWebviewConfigState.ts +++ b/apps/web/src/browser/previewWebviewConfigState.ts @@ -5,7 +5,6 @@ import type { EnvironmentId, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -91,5 +90,5 @@ export function usePreviewWebviewConfig( profileId?: string, ): DesktopPreviewWebviewConfig | null { const result = useAtomValue(previewWebviewConfigAtom(configKey(environmentId, profileId))); - return Option.getOrNull(AsyncResult.value(result)); + return AsyncResult.getOrElse(result, () => null); } diff --git a/apps/web/src/cloud/primaryCloudLinkState.ts b/apps/web/src/cloud/primaryCloudLinkState.ts index 9672f2a00366e..867008fbc3bef 100644 --- a/apps/web/src/cloud/primaryCloudLinkState.ts +++ b/apps/web/src/cloud/primaryCloudLinkState.ts @@ -8,7 +8,6 @@ import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { HttpClient } from "effect/http"; @@ -90,7 +89,7 @@ export function usePrimaryCloudLinkState() { } return { - data: result._tag === "Failure" ? null : Option.getOrNull(AsyncResult.value(result)), + data: result._tag === "Failure" ? null : AsyncResult.getOrElse(result, () => null), error, isPending: result.waiting, refresh, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c6d9cfd534b86..6af016c18ce91 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -185,11 +185,9 @@ import { toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION, + providerInstanceEntriesFor, shouldShowInstanceBadge, - sortProviderInstanceEntries, } from "../providerInstances"; import { useClientSettings, @@ -1237,12 +1235,10 @@ export default function ChatView(props: ChatViewProps) { automaticEnvironment, autoUpdateEnvironments, }); - const providerInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProject?.id ?? null, - ), + const providerInstanceEntries = providerInstanceEntriesFor( + providerStatuses, + settings, + activeProject?.id ?? null, ); const activeThreadModelInstanceId = activeThread?.modelSelection.instanceId; const { selectedProviderEntry, requestedDriverKind } = resolveComposerProviderSelection({ diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 682172f1ff308..0af0e063bdea3 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { scopeProjectRef, scopedThreadKey, @@ -325,8 +326,10 @@ export function ThreadPullRequestsMiniList({ pullRequests: ReadonlyArray; onOpenPullRequest?: (event: MouseEvent, url: string) => void; }) { - const lines = pullRequestListLines( - resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests)), + const lines = pipe( + visibleThreadPullRequests(pullRequests), + resolveThreadPullRequestChains, + pullRequestListLines, ); if (lines.length === 0) return null; return ( diff --git a/apps/web/src/components/chat/LinkRequestCard.tsx b/apps/web/src/components/chat/LinkRequestCard.tsx index 9d9327368a5ad..728c8fe61efa2 100644 --- a/apps/web/src/components/chat/LinkRequestCard.tsx +++ b/apps/web/src/components/chat/LinkRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { useAtomValue } from "@effect/atom-react"; import { type LinkableMachine, @@ -106,7 +107,7 @@ function PendingLinkRequest(props: { setDeclining(false), ); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; @@ -311,7 +312,7 @@ function LinkRequestDialog(props: { // The card switches to its answered row once the item updates. close(); } else if (!isAtomCommandInterrupted(result)) { - setError(linkRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), linkRequestFailureMessage, setError); } }; diff --git a/apps/web/src/components/chat/SecretRequestCard.tsx b/apps/web/src/components/chat/SecretRequestCard.tsx index f8dc7579adcaf..50992c2edebf5 100644 --- a/apps/web/src/components/chat/SecretRequestCard.tsx +++ b/apps/web/src/components/chat/SecretRequestCard.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { SECRET_REQUEST_DEFAULT_PLACEHOLDER, secretRequestAnswerInput, @@ -86,7 +87,7 @@ function PendingSecretRequestForm(props: { return; } if (!isAtomCommandInterrupted(result)) { - setError(secretRequestFailureMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), secretRequestFailureMessage, setError); } }; diff --git a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx index f954d275ac6fd..2da6019787f5a 100644 --- a/apps/web/src/components/chat/ThreadDetailsPrRows.tsx +++ b/apps/web/src/components/chat/ThreadDetailsPrRows.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { ThreadDetailsControl } from "./ThreadDetailsControl"; import type { EnvironmentId, ScopedThreadRef, ThreadPullRequestLink } from "@t3tools/contracts"; import { @@ -94,7 +95,7 @@ export function ThreadDetailsPrRows({ const rest = currentLink === null ? [] - : pullRequestListLines(resolveThreadPullRequestChains(visibleThreadPullRequests(links))) + : pipe(visibleThreadPullRequests(links), resolveThreadPullRequestChains, pullRequestListLines) .map((line) => line.link) .filter((link) => threadPullRequestKeyOf(link) !== threadPullRequestKeyOf(currentLink)); if (rest.length === 0) return currentRow; diff --git a/apps/web/src/components/chat/useComposerProviderModel.ts b/apps/web/src/components/chat/useComposerProviderModel.ts index 24920d1593b45..7fd1130859fec 100644 --- a/apps/web/src/components/chat/useComposerProviderModel.ts +++ b/apps/web/src/components/chat/useComposerProviderModel.ts @@ -12,10 +12,8 @@ import { } from "@t3tools/contracts"; import { NO_PROVIDER_MODEL_SELECTION, + providerInstanceEntriesFor, type ProviderInstanceEntry, - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, } from "../../providerInstances"; import { type UnifiedSettings } from "@t3tools/contracts/settings"; import { getComposerPromptInjectionState, getComposerProviderState } from "./composerProviderState"; @@ -52,12 +50,10 @@ export function useComposerProviderModel( lockedInstanceId, } = input; const draftModelState = useComposerDraftModelState(threadRef); - const providerInstanceEntries: ReadonlyArray = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providerStatuses), - settings, - activeProjectId, - ), + const providerInstanceEntries: ReadonlyArray = providerInstanceEntriesFor( + providerStatuses, + settings, + activeProjectId, ); const selection = resolveComposerProviderSelection({ entries: providerInstanceEntries, diff --git a/apps/web/src/components/device/DeviceStreamView.tsx b/apps/web/src/components/device/DeviceStreamView.tsx index e237a4690b7cc..22ae466acb0ed 100644 --- a/apps/web/src/components/device/DeviceStreamView.tsx +++ b/apps/web/src/components/device/DeviceStreamView.tsx @@ -1,6 +1,5 @@ import type { DevicePlatform, EnvironmentId } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useId, useRef, useState, type ReactNode } from "react"; @@ -197,7 +196,7 @@ export function DeviceStreamView(props: { ? deviceAxElementsAtom({ access, platform: props.platform, deviceId: props.deviceId }) : NO_AX_ATOM, ); - const axElements = Option.getOrElse(AsyncResult.value(axResult), () => NO_AX_ELEMENTS); + const axElements = AsyncResult.getOrElse(axResult, () => NO_AX_ELEMENTS); const pointerActive = useRef(false); const normalizedPoint = (event: React.PointerEvent) => { diff --git a/apps/web/src/components/files/extendedDocumentPreview.ts b/apps/web/src/components/files/extendedDocumentPreview.ts index 589ae62265c28..46cf7dbb67a83 100644 --- a/apps/web/src/components/files/extendedDocumentPreview.ts +++ b/apps/web/src/components/files/extendedDocumentPreview.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { IMAGE_DIMENSIONS_HEADER_BYTES, readImageDimensions, @@ -221,7 +222,7 @@ const HTML_TAGS = new Set([ "dd", ]); async function epubHtml(node: XmlNode, archive: JSZip, base: string): Promise { - if (node.name === "#text") return boundedMarkup(escape(text(node))); + if (node.name === "#text") return pipe(text(node), escape, boundedMarkup); const tag = node.name .replace(/^\{[^}]*\}/, "") @@ -249,7 +250,7 @@ async function epubHtml(node: XmlNode, archive: JSZip, base: string): Promise${children}`; } async function odfHtml(node: XmlNode, archive: JSZip): Promise { - if (node.name === "#text") return boundedMarkup(escape(text(node))); + if (node.name === "#text") return pipe(text(node), escape, boundedMarkup); if (node.name === "text:s" || node.name === "text:tab") return boundedMarkup( `${node.name === "text:tab" ? " " : " ".repeat(repeat(node.attrs["text:c"], 100))}`, diff --git a/apps/web/src/components/files/office/fonts.ts b/apps/web/src/components/files/office/fonts.ts index 6328d294d8749..cdac25d4bbe14 100644 --- a/apps/web/src/components/files/office/fonts.ts +++ b/apps/web/src/components/files/office/fonts.ts @@ -1,4 +1,5 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. +import { pipe } from "effect/Function"; import type { BundledFontProvider } from "@betteroffice/docx/layout"; import { loadBundledFontBytes, @@ -23,9 +24,9 @@ export function wordFonts(): BundledFontProvider { return { resolve: (family, bold, italic) => - loader(shippedFace(resolveMetricCompatFace(family, bold, italic))), + pipe(resolveMetricCompatFace(family, bold, italic), shippedFace, loader), resolveFamily: (family, bold, italic) => - loader(shippedFace(resolveBundledFamilyFace(family, bold, italic))), + pipe(resolveBundledFamilyFace(family, bold, italic), shippedFace, loader), resolveScriptFallback: (script, bold, italic) => loader(scriptFace(script, bold, italic)), resolveLastResort: (family, bold, italic) => () => loadFace(lastResortFace(family, bold, italic, "word")), diff --git a/apps/web/src/components/files/office/slide-text.ts b/apps/web/src/components/files/office/slide-text.ts index 396c0cb0b2761..6afe9a6f61b9a 100644 --- a/apps/web/src/components/files/office/slide-text.ts +++ b/apps/web/src/components/files/office/slide-text.ts @@ -1,4 +1,5 @@ // Adapted from opencode (MIT). Copyright (c) 2025 opencode. See LICENSE.opencode. +import { pipe } from "effect/Function"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { inspectPresentation, type DeckSnapshot } from "@betteroffice/pptx"; @@ -131,50 +132,58 @@ const scriptRanges: readonly (readonly [FallbackScript, RegExp])[] = [ * engine's inspection does not decode, the slides' own runs stand in, without theme fonts or hidden flags. */ export function deckText(bytes: Uint8Array, snapshot: DeckSnapshot): DeckText { - return Option.match(decodeInspected(inspectPresentation(withoutMedia(bytes))), { - onNone: () => fromSnapshot(snapshot), - onSome: (inspected) => { - const schemes = - inspected.themes?.flatMap((theme) => - theme.theme?.fontScheme ? [theme.theme.fontScheme] : [], - ) ?? []; - const minor = schemes[0]?.minorFont.latin ?? ""; - - const drawings = [ - ...inspected.slides.flatMap((slide) => slide.drawings), - ...(inspected.layouts ?? []).flatMap((layout) => layout.drawings), - ...(inspected.masters ?? []).flatMap((master) => master.drawings), - ]; - - const defaults = (inspected.masters ?? []).flatMap((master) => - Object.values(master.textStyles ?? {}).flatMap((levels) => - levels.flatMap((level) => - level.defaultRun ? [{ text: "", style: level.defaultRun }] : [], + return pipe( + withoutMedia(bytes), + inspectPresentation, + decodeInspected, + Option.match({ + onNone: () => fromSnapshot(snapshot), + onSome: (inspected) => { + const schemes = + inspected.themes?.flatMap((theme) => + theme.theme?.fontScheme ? [theme.theme.fontScheme] : [], + ) ?? []; + const minor = schemes[0]?.minorFont.latin ?? ""; + + const drawings = [ + ...inspected.slides.flatMap((slide) => slide.drawings), + ...(inspected.layouts ?? []).flatMap((layout) => layout.drawings), + ...(inspected.masters ?? []).flatMap((master) => master.drawings), + ]; + + const defaults = (inspected.masters ?? []).flatMap((master) => + Object.values(master.textStyles ?? {}).flatMap((levels) => + levels.flatMap((level) => + level.defaultRun ? [{ text: "", style: level.defaultRun }] : [], + ), ), - ), - ); - - return summarize( - [...drawings.flatMap(drawingRuns), ...defaults].flatMap((run) => { - const family = run.style?.fontFamily; - if (!schemes.length || (family && !family.startsWith("+mn") && !family.startsWith("+mj"))) - return [run]; - return schemes.map((scheme) => ({ - ...run, - style: { - ...run.style, - fontFamily: family?.startsWith("+mj") - ? scheme.majorFont.latin - : scheme.minorFont.latin, - }, - })); - }), - (family) => (family?.startsWith("+") ? "" : (family ?? "")), - minor, - inspected.slides.map((slide) => slide.hidden ?? false), - ); - }, - }); + ); + + return summarize( + [...drawings.flatMap(drawingRuns), ...defaults].flatMap((run) => { + const family = run.style?.fontFamily; + if ( + !schemes.length || + (family && !family.startsWith("+mn") && !family.startsWith("+mj")) + ) + return [run]; + return schemes.map((scheme) => ({ + ...run, + style: { + ...run.style, + fontFamily: family?.startsWith("+mj") + ? scheme.majorFont.latin + : scheme.minorFont.latin, + }, + })); + }), + (family) => (family?.startsWith("+") ? "" : (family ?? "")), + minor, + inspected.slides.map((slide) => slide.hidden ?? false), + ); + }, + }), + ); } /** A file in the deck's zip package: where its central directory record and its local data sit. */ diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 3da7fdd6da850..4d7e9ae90d6b9 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -207,7 +207,7 @@ export function useProjectEntriesQuery( const refreshAtom = useAtomRefresh(atom); const refresh = () => refreshAtom(); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error: fileAccess.isPending ? null : canReadFiles @@ -263,7 +263,7 @@ export function useProjectFileQuery( const result = useAtomValue(atom); const refreshAtom = useAtomRefresh(atom); const refresh = () => refreshAtom(); - const readData = Option.getOrNull(AsyncResult.value(result)); + const readData = AsyncResult.getOrElse(result, () => null); const isMissing = readData?.exists === false; const optimisticResult = useAtomValue( optimisticFileAtom(environmentId, cwd, relativePath ?? EMPTY_PROJECT_FILE_PATH), diff --git a/apps/web/src/components/media/MediaActions.tsx b/apps/web/src/components/media/MediaActions.tsx index 314f7a6435d28..f45ba1e7abc78 100644 --- a/apps/web/src/components/media/MediaActions.tsx +++ b/apps/web/src/components/media/MediaActions.tsx @@ -16,7 +16,6 @@ import { sessionGrantsScope, type SessionGrantInput, } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { useRef, useState, type ReactElement } from "react"; @@ -53,7 +52,7 @@ function allowsHostMedia(session: SessionGrantInput | null) { function canReadHostMedia(environmentId: EnvironmentId | null): boolean { if (environmentId === null) return true; const result = appAtomRegistry.get(environmentSession.sessionStateAtom(environmentId)); - return result._tag !== "Failure" && allowsHostMedia(Option.getOrNull(AsyncResult.value(result))); + return result._tag !== "Failure" && allowsHostMedia(AsyncResult.getOrElse(result, () => null)); } export function useMediaActions(source: MediaActionSource) { diff --git a/apps/web/src/components/preview/agentOpenedPreviews.ts b/apps/web/src/components/preview/agentOpenedPreviews.ts index ccb133074baad..8273258d51e54 100644 --- a/apps/web/src/components/preview/agentOpenedPreviews.ts +++ b/apps/web/src/components/preview/agentOpenedPreviews.ts @@ -10,7 +10,6 @@ import type { ScopedThreadRef, ThreadId, } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { getClientSettings } from "../../hooks/useSettings"; @@ -222,7 +221,7 @@ function watchEnvironmentDevices(environmentId: EnvironmentId): () => void { return appAtomRegistry.subscribe( deviceEnvironment.state({ environmentId, input: {} }), (result) => { - const state = Option.getOrNull(AsyncResult.value(result)); + const state = AsyncResult.getOrElse(result, () => null); if (state === null) return; applyDeviceSnapshot(environmentId, state, { sheetLayout: window.matchMedia(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY).matches, diff --git a/apps/web/src/components/pullRequest/filesViewedStore.ts b/apps/web/src/components/pullRequest/filesViewedStore.ts index 64ae67a355192..cae490ded8d66 100644 --- a/apps/web/src/components/pullRequest/filesViewedStore.ts +++ b/apps/web/src/components/pullRequest/filesViewedStore.ts @@ -8,7 +8,6 @@ import type { PullRequestFilesViewedResult, PullRequestRef, } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { appAtomRegistry } from "~/rpc/atomRegistry"; @@ -238,7 +237,7 @@ const filesViewedStore = createFilesViewedStore({ subscribeAnswers: (target, onAnswer) => appAtomRegistry.subscribe( filesViewedAtom(target), - (result) => onAnswer(Option.getOrNull(AsyncResult.value(result))), + (result) => onAnswer(AsyncResult.getOrElse(result, () => null)), { immediate: true }, ), send: ({ environmentId, reference }, files) => diff --git a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx index d9c05ae1a9917..f73755ed7d452 100644 --- a/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx +++ b/apps/web/src/components/pullRequest/guide/PullRequestGuideRunCoordinator.tsx @@ -1,7 +1,6 @@ import type { AnyRouter } from "@tanstack/react-router"; import type { PullRequestReviewGuideResult } from "@t3tools/contracts"; import { CircleAlertIcon, CircleCheckIcon } from "lucide-react"; -import * as Option from "effect/Option"; import { AsyncResult, type AtomRegistry } from "effect/reactivity"; import { getClientSettings } from "~/hooks/useSettings"; @@ -159,7 +158,7 @@ function watchGuideRun( const unsubscribe = registry.subscribe( atom, (result) => { - const data = Option.getOrNull(AsyncResult.value(result)); + const data = AsyncResult.getOrElse(result, () => null); if (data === null || data === seen) return; seen = data; if (subscribing && watch.answeredRunning === true) return; diff --git a/apps/web/src/components/settings/AcpRegistryIcon.tsx b/apps/web/src/components/settings/AcpRegistryIcon.tsx index b7a5938ee8942..4a2a2143fe0e5 100644 --- a/apps/web/src/components/settings/AcpRegistryIcon.tsx +++ b/apps/web/src/components/settings/AcpRegistryIcon.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useId, useState } from "react"; import { @@ -110,10 +109,9 @@ export function AcpRegistryAgentIcon({ const iconUrl = resolveOfficialAcpRegistryIconUrl(icon); // Render only after the load pipeline resolves, so the fallback icon does // not flash a broken image. - const source = Option.getOrNull( - AsyncResult.value( - useAtomValue(iconUrl === null ? NO_ICON_SOURCE_ATOM : acpRegistryIconSourceAtom(iconUrl)), - ), + const source = AsyncResult.getOrElse( + useAtomValue(iconUrl === null ? NO_ICON_SOURCE_ATOM : acpRegistryIconSourceAtom(iconUrl)), + () => null, ); const [image, setImage] = useState<{ readonly source: string; diff --git a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx index 50967ab2026e9..a3612a609418c 100644 --- a/apps/web/src/components/settings/AcpRegistrySearchStep.tsx +++ b/apps/web/src/components/settings/AcpRegistrySearchStep.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { ACP_REGISTRY_MAX_AGENTS, AuthProvidersManageScope } from "@t3tools/contracts"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { @@ -111,7 +112,7 @@ export function useAcpRegistrySearch(environmentId: EnvironmentId, enabled: bool return; } if (!isAtomCommandInterrupted(result)) { - setPrepareError(errorMessage(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), errorMessage, setPrepareError); } }, }; diff --git a/apps/web/src/components/settings/AddEnvironmentDialog.tsx b/apps/web/src/components/settings/AddEnvironmentDialog.tsx index 22bb624c27533..23a439c63a475 100644 --- a/apps/web/src/components/settings/AddEnvironmentDialog.tsx +++ b/apps/web/src/components/settings/AddEnvironmentDialog.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { ChevronsLeftRightEllipsisIcon, PlusIcon, TerminalIcon } from "lucide-react"; import { useAtomValue } from "@effect/atom-react"; import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from "react"; @@ -155,7 +156,7 @@ export function AddEnvironmentDialog({ }); if (result._tag === "Failure") { if (!isAtomCommandInterrupted(result)) { - onErrorChange(formatDesktopSshConnectionError(squashAtomCommandFailure(result))); + pipe(squashAtomCommandFailure(result), formatDesktopSshConnectionError, onErrorChange); } setIsAddingSavedBackend(false); return; diff --git a/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx b/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx index c655833ca7c82..d896efe455216 100644 --- a/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx +++ b/apps/web/src/components/settings/ConnectionDiagnosisDetails.tsx @@ -28,8 +28,8 @@ import { stackedThreadToast, toastManager } from "../ui/toast"; */ export function useConnectionDiagnosis(environment: EnvironmentPresentation) { const environmentId = environment.environmentId; - const state = Option.getOrElse( - AsyncResult.value(useAtomValue(environmentCatalog.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + useAtomValue(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); const relayStatus = useRelayEnvironmentDiscovery().environments.get(environmentId)?.status; diff --git a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx index 9941be071c799..0bfa19498f6d9 100644 --- a/apps/web/src/components/settings/ProjectDefaultsSettings.tsx +++ b/apps/web/src/components/settings/ProjectDefaultsSettings.tsx @@ -12,10 +12,8 @@ import { resolveEffectiveDefaultModelSelection, } from "../../modelSelection"; import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, + providerInstanceEntriesFor, resolveDefaultProviderModelSelection, - sortProviderInstanceEntries, } from "../../providerInstances"; import { useEnvironmentProviders, useEnvironments } from "../../state/environments"; import { EMPTY_SERVER_PROVIDERS } from "../../state/server"; @@ -70,13 +68,7 @@ export function ProjectDefaultsSettings({ category }: { category: ProjectSetting const selection = storedSelection ? resolveEffectiveDefaultModelSelection(settings, providers, storedSelection) : null; - const entries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providers), - settings, - target?.projectId ?? null, - ), - ); + const entries = providerInstanceEntriesFor(providers, settings, target?.projectId ?? null); const modelOptions = getCustomModelOptionsByInstance( settings, providers, diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 983503c96b5fe..1f64fe501af1d 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -1,6 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; import { connectionStatusTitle } from "@t3tools/client-runtime/connection"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { Clock3Icon, @@ -48,11 +47,7 @@ import { import { formatRelativeTime } from "../../timestampFormat"; import { useEnvironmentSettings } from "../../hooks/useSettings"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { usePrimaryCloudLinkState } from "../../cloud/primaryCloudLinkState"; import { requestConfirmDialog } from "../../confirmDialog"; import { webhookAddress } from "@t3tools/client-runtime/webhook-address"; @@ -492,7 +487,7 @@ function ScheduledTaskEnvironmentList({ const states = environments.map((environment) => { const index = connectedIds.indexOf(environment.environmentId); const query = index < 0 ? null : queries[index]!; - const data = query === null ? null : Option.getOrNull(AsyncResult.value(query)); + const data = query === null ? null : AsyncResult.getOrElse(query, () => null); return { environment, connected: index >= 0, @@ -1148,12 +1143,10 @@ function ScheduledTaskEditorForm({ : task; const selectedProjectId = draft.projectId || projects[0]?.id || ""; const selectedProject = projects.find((project) => project.id === selectedProjectId); - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(providers), - settings, - selectedProject?.id ?? null, - ), + const instanceEntries = providerInstanceEntriesFor( + providers, + settings, + selectedProject?.id ?? null, ); const firstInstance = instanceEntries[0]; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index a4aadac731081..183c12ddf1a49 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -61,11 +61,7 @@ import { resolveAppModelSelectionState, selectsPlanAgent, } from "../../modelSelection"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { ensureLocalApi, readLocalApi } from "../../localApi"; import { isMacPlatform } from "../../lib/utils"; import { useEnvironmentProviders } from "../../state/environments"; @@ -1501,12 +1497,10 @@ export function ThreadsSettingsPanel() { const textGenInstanceId = textGenerationModelSelection.instanceId; const textGenModel = textGenerationModelSelection.model; const textGenModelOptions = textGenerationModelSelection.options; - const textGenerationModelInstanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(textGenerationProviders), - settings, - target?.projectId ?? null, - ), + const textGenerationModelInstanceEntries = providerInstanceEntriesFor( + textGenerationProviders, + settings, + target?.projectId ?? null, ); const hasTextGenerationProvider = textGenerationModelInstanceEntries.some( (entry) => entry.enabled && entry.isAvailable, diff --git a/apps/web/src/components/settings/SettingsScopeContext.tsx b/apps/web/src/components/settings/SettingsScopeContext.tsx index c6cae612b7716..294ed8b2fd41d 100644 --- a/apps/web/src/components/settings/SettingsScopeContext.tsx +++ b/apps/web/src/components/settings/SettingsScopeContext.tsx @@ -1,7 +1,6 @@ import { type EnvironmentId, T3_PROJECT_FILE_NAME, type T3ProjectFile } from "@t3tools/contracts"; import { parseT3ProjectFile } from "@t3tools/shared/t3ProjectFile"; import { useAtomValue } from "@effect/atom-react"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { createContext, type ReactNode, useContext } from "react"; @@ -29,7 +28,7 @@ const memberProjectFilesAtom = Atom.family((membersKey: string) => { if (result.waiting) continue; const data = get(optimisticFileAtom(environmentId, workspaceRoot, T3_PROJECT_FILE_NAME))?.data ?? - Option.getOrNull(AsyncResult.value(result)); + AsyncResult.getOrElse(result, () => null); files.set( physicalProjectKey, data === null || data.exists === false || data.truncated diff --git a/apps/web/src/components/settings/SourceControlWritingSettings.tsx b/apps/web/src/components/settings/SourceControlWritingSettings.tsx index 2b54bd04d0ffb..dd3618902f8b5 100644 --- a/apps/web/src/components/settings/SourceControlWritingSettings.tsx +++ b/apps/web/src/components/settings/SourceControlWritingSettings.tsx @@ -19,11 +19,7 @@ import { } from "./useScopedSettings"; import { useScopedModelDisabledReason } from "./useScopedModelAvailability"; import { useSettingsScope } from "./SettingsScopeContext"; -import { - applyProviderInstanceSettings, - deriveProviderInstanceEntries, - sortProviderInstanceEntries, -} from "../../providerInstances"; +import { providerInstanceEntriesFor } from "../../providerInstances"; import { getCustomModelOptionsByInstance, resolveAppModelSelectionState, @@ -110,12 +106,10 @@ export function SourceControlWritingSettingsSection() { const textGenerationProviders = serverProviders.filter( (provider) => provider.supportsTextGeneration !== false, ); - const instanceEntries = sortProviderInstanceEntries( - applyProviderInstanceSettings( - deriveProviderInstanceEntries(textGenerationProviders), - settings, - target?.projectId ?? null, - ), + const instanceEntries = providerInstanceEntriesFor( + textGenerationProviders, + settings, + target?.projectId ?? null, ); const projectEnabledInstanceIds = new Set( instanceEntries diff --git a/apps/web/src/components/usage/UsagePriceOverrides.tsx b/apps/web/src/components/usage/UsagePriceOverrides.tsx index bbc481f860c25..eb47ac5586ed3 100644 --- a/apps/web/src/components/usage/UsagePriceOverrides.tsx +++ b/apps/web/src/components/usage/UsagePriceOverrides.tsx @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { AuthSettingsWriteScope, type EnvironmentId, sessionGrantsScope } from "@t3tools/contracts"; import { ChevronDownIcon, PlusIcon, RotateCcwIcon, XIcon } from "lucide-react"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useRef, useState } from "react"; @@ -46,7 +45,7 @@ const priceTargetsAtom = Atom.make((get): readonly UsagePriceTarget[] => [...get(environmentPresentations.presentationsAtom)].map(([environmentId, environment]) => { const settings = get(serverEnvironment.settingsValueAtom(environmentId)); const session = get(environmentSession.sessionStateAtom(environmentId)); - const sessionData = Option.getOrNull(AsyncResult.value(session)); + const sessionData = AsyncResult.getOrElse(session, () => null); const access = session._tag === "Failure" ? "denied" diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 22c7c8a5c3553..e823d2ddf33a3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -4194,9 +4194,7 @@ export function subscribeComposerDraftModelReconciliation(input: { const thread = get(input.threadShellAtom(ref)); if (!thread || !draftModelPickMatchesThread(pick, thread.modelSelection)) continue; // Status and selection must come from the same emission; derived atoms can update apart. - const shell = Option.getOrNull( - AsyncResult.value(get(input.shellStateAtom(ref.environmentId))), - ); + const shell = AsyncResult.getOrElse(get(input.shellStateAtom(ref.environmentId)), () => null); if (shell?.status !== "live") continue; const authoritativeThread = Option.getOrNull(shell.snapshot)?.threads.find( (candidate) => candidate.id === ref.threadId, diff --git a/apps/web/src/environments/primary/sessionState.ts b/apps/web/src/environments/primary/sessionState.ts index 34dc68b2f3ca1..766421a17afd5 100644 --- a/apps/web/src/environments/primary/sessionState.ts +++ b/apps/web/src/environments/primary/sessionState.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; @@ -30,7 +29,7 @@ export function usePrimarySessionState() { error = cause instanceof Error ? cause.message : "Could not read environment session."; } return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error, isPending: result.waiting, refresh: refreshPrimarySessionState, diff --git a/apps/web/src/filePathDisplay.ts b/apps/web/src/filePathDisplay.ts index 7fee707bf4c0d..9d59a83630a5c 100644 --- a/apps/web/src/filePathDisplay.ts +++ b/apps/web/src/filePathDisplay.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { fileBasename, stripSlashPrefixedWindowsDrive, @@ -26,8 +27,10 @@ export function formatWorkspaceRelativePath( let displayPath = normalizedPath; if (workspaceRoot) { - const normalizedWorkspaceRoot = stripSlashPrefixedWindowsDrive( - normalizePathSeparators(trimTrailingPathSeparators(workspaceRoot)), + const normalizedWorkspaceRoot = pipe( + trimTrailingPathSeparators(workspaceRoot), + normalizePathSeparators, + stripSlashPrefixedWindowsDrive, ); const workspaceLabel = fileBasename(normalizedWorkspaceRoot); const caseInsensitive = isWindowsAbsolutePath(stripSlashPrefixedWindowsDrive(workspaceRoot)); diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts index 87733457a417b..2c24f13760092 100644 --- a/apps/web/src/hooks/useSettings.ts +++ b/apps/web/src/hooks/useSettings.ts @@ -574,7 +574,7 @@ function useSharedSettingsSyncTargetIds(includePending = false): ReadonlyArray null); return session !== null && sessionGrantsScope(session, AuthSettingsWriteScope) ? [environmentId] : []; diff --git a/apps/web/src/hooks/useTheme.ts b/apps/web/src/hooks/useTheme.ts index 6addce1543cb1..2467d89091b24 100644 --- a/apps/web/src/hooks/useTheme.ts +++ b/apps/web/src/hooks/useTheme.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { DesktopBridge } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import * as Schema from "effect/Schema"; @@ -518,7 +519,7 @@ export function setTheme(next: Theme): boolean { if (typeof window === "undefined") return false; try { // Save the current mode first; inferring it from the new theme can switch dark to light. - writeAppearanceModePreference(readAppearanceModePreference(getStored())); + pipe(getStored(), readAppearanceModePreference, writeAppearanceModePreference); // A whole theme replaces the mix; retain it to restore on a failed preference write. const previousHalvesRaw = window.localStorage.getItem(THEME_HALVES_STORAGE_KEY); window.localStorage.removeItem(THEME_HALVES_STORAGE_KEY); diff --git a/apps/web/src/lib/assistantCitationNavigation.ts b/apps/web/src/lib/assistantCitationNavigation.ts index 70d2c160a1a1e..1320b73aa33c5 100644 --- a/apps/web/src/lib/assistantCitationNavigation.ts +++ b/apps/web/src/lib/assistantCitationNavigation.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { AssistantCitation } from "@t3tools/contracts"; import { formatAssistantCitationHref, @@ -27,8 +28,11 @@ export function assistantCitationFromLocation(href: string) { const hash = href.slice(hashIndex + 1); if (!hash.startsWith(CITATION_HASH_PREFIX) || hash.length > 140_000) return null; try { - return parseAssistantCitationHref( - Result.getOrThrow(Base64Url.decodeString(hash.slice(CITATION_HASH_PREFIX.length))), + return pipe( + hash.slice(CITATION_HASH_PREFIX.length), + Base64Url.decodeString, + Result.getOrThrow, + parseAssistantCitationHref, ); } catch { return null; diff --git a/apps/web/src/onboarding/useProjectScans.ts b/apps/web/src/onboarding/useProjectScans.ts index e345d77c1170c..bf0ac3a5aecb3 100644 --- a/apps/web/src/onboarding/useProjectScans.ts +++ b/apps/web/src/onboarding/useProjectScans.ts @@ -1,6 +1,5 @@ import { RegistryContext, useAtomValue } from "@effect/atom-react"; import type { EnvironmentId } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { useContext } from "react"; @@ -18,7 +17,7 @@ export function useProjectScans(environmentIds: readonly EnvironmentId[]) { const result = results[index]!; return { environmentId, - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, isPending: result.waiting || result._tag === "Initial", refresh: () => registry.refresh(scanAtom(environmentId)), diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index 2f17157e6b505..a3364df9a15d6 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { DEFAULT_MODEL_BY_PROVIDER, defaultInstanceIdForDriver, @@ -147,7 +148,20 @@ function applyProviderInstanceBranding( }; } -export function sortProviderInstanceEntries( +/** Picker order: each driver's default instance first, then its custom instances. */ +export function providerInstanceEntriesFor( + providers: ReadonlyArray, + settings: Pick, + projectId: ProjectId | null, +): ReadonlyArray { + return pipe( + deriveProviderInstanceEntries(providers), + (entries) => applyProviderInstanceSettings(entries, settings, projectId), + sortProviderInstanceEntries, + ); +} + +function sortProviderInstanceEntries( entries: ReadonlyArray, ): ReadonlyArray { const byKind = new Map(); diff --git a/apps/web/src/state/projectClones.ts b/apps/web/src/state/projectClones.ts index 544b48f96109b..10904d346937b 100644 --- a/apps/web/src/state/projectClones.ts +++ b/apps/web/src/state/projectClones.ts @@ -1,7 +1,6 @@ import { useAtomValue } from "@effect/atom-react"; import { parseScopedProjectKey, scopedProjectKey } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectCloneSnapshot, ScopedProjectRef } from "@t3tools/contracts"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { environmentServerConfigsAtom } from "./server"; @@ -16,7 +15,7 @@ export const environmentProjectClonesAtom = Atom.family((environmentId: Environm Atom.make((get): ReadonlyArray => { if (!get(environmentServerConfigsAtom).has(environmentId)) return EMPTY_CLONES; const result = get(sourceControlEnvironment.projectClones({ environmentId, input: {} })); - return Option.getOrElse(AsyncResult.value(result), () => EMPTY_CLONES); + return AsyncResult.getOrElse(result, () => EMPTY_CLONES); }).pipe(Atom.withLabel(`web-project-clones:${environmentId}`)), ); diff --git a/apps/web/src/state/pullRequests.ts b/apps/web/src/state/pullRequests.ts index 1a5742a63df2d..c8ac23ced0584 100644 --- a/apps/web/src/state/pullRequests.ts +++ b/apps/web/src/state/pullRequests.ts @@ -547,7 +547,7 @@ function createMergedEnvironmentQuery( if (result._tag === "Failure" && error === null) { error = formatEnvironmentQueryError(result.cause); } - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); if (value !== null) values.push([target.environmentId, value]); } return { values, error, isPending }; @@ -603,7 +603,7 @@ export function usePullRequestTurnRefreshes( export function usePullRequestTurnRefresh(environmentId: EnvironmentId): number | null { const result = useAtomValue(pullRequestEnvironment.refreshes({ environmentId, input: {} })); - return Option.getOrNull(AsyncResult.value(result)); + return AsyncResult.getOrElse(result, () => null); } export interface MergedPullRequestListView { diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 5a999922da380..636913c8898c9 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -27,7 +27,6 @@ import type { VcsRef, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useEffect, useState } from "react"; @@ -208,7 +207,7 @@ export function usePaginatedBranches(target: VcsRefTarget) { pagesKey === null ? EMPTY_VCS_REF_PAGES_ATOM : vcsRefPagesAtom(pagesKey), ); const values = results.flatMap((result) => { - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); return value === null ? [] : [value]; }); const refs = new Map(); diff --git a/apps/web/src/state/query.ts b/apps/web/src/state/query.ts index a8e86fc56ed83..c96319e4b7553 100644 --- a/apps/web/src/state/query.ts +++ b/apps/web/src/state/query.ts @@ -40,7 +40,7 @@ export function useEnvironmentQuery( const result = useAtomValue(selectedAtom); const refresh = useAtomRefresh(selectedAtom); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), dataUpdatedAt: queryUpdatedAt(result), error: result._tag === "Failure" ? formatEnvironmentQueryError(result.cause) : null, failure: diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index cce8a8ebb8fc7..bced4e959fa8e 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -68,10 +68,11 @@ const primaryServerStateAtom = Atom.make((get): PrimaryServerState => { } const target = { environmentId, input: {} }; - const configProjection = Option.getOrNull( - AsyncResult.value(get(serverEnvironment.configProjection(target))), + const configProjection = AsyncResult.getOrElse( + get(serverEnvironment.configProjection(target)), + () => null, ); - const welcome = Option.getOrNull(AsyncResult.value(get(serverEnvironment.welcome(target)))); + const welcome = AsyncResult.getOrElse(get(serverEnvironment.welcome(target)), () => null); return { config: get(serverEnvironment.configValueAtom(environmentId)), @@ -98,8 +99,9 @@ export const primaryServerLegacyThreadMigrationAtom = Atom.make( if (environmentId === null) { return null; } - return Option.getOrNull( - AsyncResult.value(get(serverEnvironment.legacyThreadMigration({ environmentId, input: {} }))), + return AsyncResult.getOrElse( + get(serverEnvironment.legacyThreadMigration({ environmentId, input: {} })), + () => null, ); }, ).pipe(Atom.withLabel("web-primary-server-legacy-thread-migration")); @@ -143,8 +145,8 @@ const environmentConnectionsAtom = Atom.make((get) => { Pick >(); for (const [environmentId, entry] of get(environmentCatalog.catalogValueAtom).entries) { - const state = Option.getOrElse( - AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + get(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); environments.set(environmentId, { diff --git a/apps/web/src/state/session.ts b/apps/web/src/state/session.ts index caf3f99c07d33..13dffb510b93f 100644 --- a/apps/web/src/state/session.ts +++ b/apps/web/src/state/session.ts @@ -37,7 +37,7 @@ export function sessionHasScope( result: AsyncResult.AsyncResult, scope: AuthEnvironmentScope, ): boolean { - const session = Option.getOrNull(AsyncResult.value(result)); + const session = AsyncResult.getOrElse(result, () => null); return result._tag !== "Failure" && session !== null && sessionGrantsScope(session, scope); } @@ -94,7 +94,7 @@ export function readPreparedConnection(environmentId: EnvironmentId) { export function useEnvironmentSessionState(environmentId: EnvironmentId) { const result = useAtomValue(environmentSession.sessionStateAtom(environmentId)); return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), hasError: result._tag === "Failure", isPending: result.waiting, }; diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts index ed5a1546b2ccb..c4675d3d01301 100644 --- a/apps/web/src/state/shell.ts +++ b/apps/web/src/state/shell.ts @@ -75,8 +75,8 @@ export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => { if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) { continue; } - const connection = Option.getOrElse( - AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))), + const connection = AsyncResult.getOrElse( + get(environmentCatalog.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); if (connectionProjectionPhase(connection) !== "disconnected") { diff --git a/apps/web/src/state/sourceControlActions.ts b/apps/web/src/state/sourceControlActions.ts index dc21df5782468..bfe883122abec 100644 --- a/apps/web/src/state/sourceControlActions.ts +++ b/apps/web/src/state/sourceControlActions.ts @@ -21,7 +21,6 @@ import { type ThreadId, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; -import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; import { appAtomRegistry } from "../rpc/atomRegistry"; @@ -373,15 +372,14 @@ export function readCachedPullRequestResolution( if (target.environmentId === null || target.cwd === null || target.reference === null) { return null; } - return Option.getOrNull( - AsyncResult.value( - appAtomRegistry.get( - gitEnvironment.pullRequestResolution({ - environmentId: target.environmentId, - input: { cwd: target.cwd, reference: target.reference }, - }), - ), + return AsyncResult.getOrElse( + appAtomRegistry.get( + gitEnvironment.pullRequestResolution({ + environmentId: target.environmentId, + input: { cwd: target.cwd, reference: target.reference }, + }), ), + () => null, ); } diff --git a/apps/web/src/state/terminalSessions.ts b/apps/web/src/state/terminalSessions.ts index 3683a8fba6da1..a426212cb9a44 100644 --- a/apps/web/src/state/terminalSessions.ts +++ b/apps/web/src/state/terminalSessions.ts @@ -159,8 +159,9 @@ const attachedTerminalSessionFamily = Atom.family((key: string) => { ) : null; const metadata = canRead - ? Option.getOrNull( - AsyncResult.value(get(terminalEnvironment.metadata({ environmentId, input: null }))), + ? AsyncResult.getOrElse( + get(terminalEnvironment.metadata({ environmentId, input: null })), + () => null, ) : null; const summary = @@ -172,7 +173,7 @@ const attachedTerminalSessionFamily = Atom.family((key: string) => { const buffer = attach === null ? EMPTY_TERMINAL_BUFFER_STATE - : Option.getOrElse(AsyncResult.value(attach), () => EMPTY_TERMINAL_BUFFER_STATE); + : AsyncResult.getOrElse(attach, () => EMPTY_TERMINAL_BUFFER_STATE); const state = combineTerminalSessionState(summary, buffer); return attach?._tag === "Failure" ? { ...state, error: formatEnvironmentQueryError(attach.cause), status: "error" } diff --git a/apps/web/src/state/threads.ts b/apps/web/src/state/threads.ts index 8258e123cf854..07fffa97cfbe0 100644 --- a/apps/web/src/state/threads.ts +++ b/apps/web/src/state/threads.ts @@ -67,7 +67,7 @@ export function useEnvironmentThread( ? environmentThreads.stateAtom(environmentId, threadId) : EMPTY_THREAD_STATE_ATOM, ); - const state = Option.getOrElse(AsyncResult.value(result), () => EMPTY_ENVIRONMENT_THREAD_STATE); + const state = AsyncResult.getOrElse(result, () => EMPTY_ENVIRONMENT_THREAD_STATE); return state; } diff --git a/apps/web/src/state/usage.ts b/apps/web/src/state/usage.ts index a5bb95c430c8b..9a68a5fa724f7 100644 --- a/apps/web/src/state/usage.ts +++ b/apps/web/src/state/usage.ts @@ -10,7 +10,6 @@ import { } from "@t3tools/contracts"; import { needsCursorKeychainAccess, refreshUsage } from "@t3tools/client-runtime/state/usage"; import { resolveUsageAccess } from "@t3tools/client-runtime/state/usage-access"; -import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/reactivity"; import { useState } from "react"; @@ -42,7 +41,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => const sessionResult = get(environmentSession.sessionStateAtom(environmentId)); const access = resolveUsageAccess({ connectionPhase: presentation.connection.phase, - session: Option.getOrNull(AsyncResult.value(sessionResult)), + session: AsyncResult.getOrElse(sessionResult, () => null), hasSessionError: sessionResult._tag === "Failure", }); if (!access.canReadDiagnostics) { @@ -57,7 +56,7 @@ const usageByWindowAtom = Atom.family((windowKey: string) => continue; } const result = get(serverEnvironment.usageSummary({ environmentId, input })); - const summary = Option.getOrNull(AsyncResult.value(result)); + const summary = AsyncResult.getOrElse(result, () => null); statuses.push({ environmentId, label: presentation.entry.target.label, diff --git a/apps/web/src/state/waitForQuerySettled.ts b/apps/web/src/state/waitForQuerySettled.ts index 159aed2efdc46..e5339d12cc400 100644 --- a/apps/web/src/state/waitForQuerySettled.ts +++ b/apps/web/src/state/waitForQuerySettled.ts @@ -1,4 +1,3 @@ -import * as Option from "effect/Option"; import { AsyncResult, type Atom, type AtomRegistry } from "effect/reactivity"; /** @@ -27,7 +26,7 @@ export function waitForQuerySettled(input: { const onAbort = () => finish(undefined); const check = (result: AsyncResult.AsyncResult) => { if (AsyncResult.isFailure(result)) return finish(undefined); - const settled = settle(Option.getOrNull(AsyncResult.value(result))); + const settled = settle(AsyncResult.getOrElse(result, () => null)); if (settled !== undefined) finish(settled); }; if (signal.aborted) return finish(undefined); diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts index 3a20981bd3f3d..58dfe4b53ed18 100644 --- a/apps/web/src/themePalette.ts +++ b/apps/web/src/themePalette.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { EnvironmentTheme } from "@t3tools/contracts"; import * as Equal from "effect/Equal"; import * as Option from "effect/Option"; @@ -752,13 +753,12 @@ function standardStatusColors(canvas: ThemeRgbColor): { const surfaceOf = (value: string) => mixThemeRgbColors(canvas, parseThemeRgbColor(value, canvas), surfaceMix); const readableOn = (foreground: string, surface: ThemeRgbColor) => - themeOklchToThemeColor( - solveOklchLightness( - themeRgbToOklch(parseThemeRgbColor(foreground, canvas)), - [surface], - 4.6, - appearance === "dark" ? "lighter" : "darker", - ), + pipe( + parseThemeRgbColor(foreground, canvas), + themeRgbToOklch, + (color) => + solveOklchLightness(color, [surface], 4.6, appearance === "dark" ? "lighter" : "darker"), + themeOklchToThemeColor, ); const errorSurface = surfaceOf(standard.error); const warningSurface = surfaceOf(standard.warning); diff --git a/packages/client-runtime/src/relay/managedRelayState.ts b/packages/client-runtime/src/relay/managedRelayState.ts index 94e8fe3368962..c949faf2c3225 100644 --- a/packages/client-runtime/src/relay/managedRelayState.ts +++ b/packages/client-runtime/src/relay/managedRelayState.ts @@ -4,7 +4,6 @@ import * as Cause from "effect/Cause"; import * as Clock from "effect/Clock"; import * as Data from "effect/Data"; 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 { AsyncResult, Atom, AtomRegistry } from "effect/reactivity"; @@ -264,7 +263,7 @@ export function readManagedRelaySnapshotState( ): ManagedRelaySnapshotState { const failure = result._tag === "Failure" ? describeManagedRelayFailure(result.cause) : null; return { - data: Option.getOrNull(AsyncResult.value(result)), + data: AsyncResult.getOrElse(result, () => null), error: failure?.message ?? null, errorTraceId: failure?.traceId ?? null, isPending: result.waiting, diff --git a/packages/client-runtime/src/state/archivedThreads.ts b/packages/client-runtime/src/state/archivedThreads.ts index 60c822c981c31..3a67dc1c52fcf 100644 --- a/packages/client-runtime/src/state/archivedThreads.ts +++ b/packages/client-runtime/src/state/archivedThreads.ts @@ -1,7 +1,6 @@ import { EnvironmentId, type OrchestrationV2ArchivedShellSnapshot } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import { pipe } from "effect/Function"; -import * as Option from "effect/Option"; import * as Order from "effect/Order"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -53,7 +52,7 @@ export function createArchivedThreadSnapshotsAtomFamily(options: { const result = get(options.getSnapshotAtom(environmentId)); isLoading ||= result.waiting; - const snapshot = Option.getOrNull(AsyncResult.value(result)); + const snapshot = AsyncResult.getOrElse(result, () => null); if (snapshot !== null) { snapshots.push({ environmentId, snapshot }); } diff --git a/packages/client-runtime/src/state/assets.ts b/packages/client-runtime/src/state/assets.ts index 298772ca05cc3..7017046f8bea8 100644 --- a/packages/client-runtime/src/state/assets.ts +++ b/packages/client-runtime/src/state/assets.ts @@ -257,7 +257,7 @@ export function createProjectFaviconUrlAtomFamily(input: { 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)); + return AsyncResult.getOrElse(result, () => cache.peek(target)); }).pipe(Atom.setIdleTTL(ASSET_URL_IDLE_TTL_MS)); }); return (target: ProjectFaviconTarget) => diff --git a/packages/client-runtime/src/state/commandPermissions.ts b/packages/client-runtime/src/state/commandPermissions.ts index e8008230107ad..078f7c47508bb 100644 --- a/packages/client-runtime/src/state/commandPermissions.ts +++ b/packages/client-runtime/src/state/commandPermissions.ts @@ -26,7 +26,7 @@ function makeCommandPermissions( const scopes = scopeKey.split(",").filter(Boolean) as AuthEnvironmentScope[]; if (scopes.length === 0) return true; const result = get(sessions.sessionStateAtom(environmentId)); - const session = Option.getOrNull(AsyncResult.value(result)); + const session = AsyncResult.getOrElse(result, () => null); return ( result._tag !== "Failure" && session !== null && diff --git a/packages/client-runtime/src/state/connections.ts b/packages/client-runtime/src/state/connections.ts index 82afc19c69a72..206f75a600416 100644 --- a/packages/client-runtime/src/state/connections.ts +++ b/packages/client-runtime/src/state/connections.ts @@ -1,6 +1,5 @@ import type { EnvironmentId as EnvironmentIdType } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -63,7 +62,7 @@ export function createEnvironmentCatalogAtoms( ); const catalogValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(catalogAtom)), () => EMPTY_ENVIRONMENT_CATALOG_STATE), + AsyncResult.getOrElse(get(catalogAtom), () => EMPTY_ENVIRONMENT_CATALOG_STATE), ).pipe(Atom.withLabel("environment-catalog-value")); const pullRequestRoutingPermissionsAtom = runtime.atom( @@ -73,7 +72,7 @@ export function createEnvironmentCatalogAtoms( { initialValue: [] as ReadonlyArray }, ); const pullRequestRoutingPermissionsValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(pullRequestRoutingPermissionsAtom)), () => []), + AsyncResult.getOrElse(get(pullRequestRoutingPermissionsAtom), () => []), ).pipe(Atom.withLabel("environment-pull-request-routing-permissions")); const setPullRequestRoutingPermission = createRuntimeCommand(runtime, { label: "environment-catalog:pull-request-routing-permission", @@ -104,7 +103,7 @@ export function createEnvironmentCatalogAtoms( ); const networkStatusValueAtom = Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(networkStatusAtom)), () => "unknown" as const), + AsyncResult.getOrElse(get(networkStatusAtom), () => "unknown" as const), ).pipe(Atom.withLabel("environment-network-status-value")); const stateAtom = Atom.family((environmentId: EnvironmentIdType) => diff --git a/packages/client-runtime/src/state/olderSettledThreads.test.ts b/packages/client-runtime/src/state/olderSettledThreads.test.ts index e8c419f779d26..571a8fbaaad74 100644 --- a/packages/client-runtime/src/state/olderSettledThreads.test.ts +++ b/packages/client-runtime/src/state/olderSettledThreads.test.ts @@ -520,7 +520,7 @@ describe("createOlderSettledThreadAtoms", () => { yield* Queue.offer(replies, Effect.succeed({ ...found, snapshotSequence: 8 })); yield* until( search, - (result) => Option.getOrNull(AsyncResult.value(result))?.snapshotSequence === 8, + (result) => AsyncResult.getOrElse(result, () => null)?.snapshotSequence === 8, ); expect(requests).toEqual([input, input]); }), diff --git a/packages/client-runtime/src/state/olderSettledThreads.ts b/packages/client-runtime/src/state/olderSettledThreads.ts index 56dcd144c0b72..86f7f86f0d1bd 100644 --- a/packages/client-runtime/src/state/olderSettledThreads.ts +++ b/packages/client-runtime/src/state/olderSettledThreads.ts @@ -213,7 +213,7 @@ export function createOlderSettledThreadAtoms( ) => Atom.Atom>, ) { const shellStateValue = (state: AsyncResult.AsyncResult) => - Option.getOrNull(AsyncResult.value(state)); + AsyncResult.getOrElse(state, () => null); const countReadAtom = createEnvironmentQueryAtomFamily(runtime, { label: "environment-data:orchestration:older-settled-counts", diff --git a/packages/client-runtime/src/state/presentation.ts b/packages/client-runtime/src/state/presentation.ts index 3c3e2ef78b906..f3ffb03b0bcfe 100644 --- a/packages/client-runtime/src/state/presentation.ts +++ b/packages/client-runtime/src/state/presentation.ts @@ -77,8 +77,8 @@ export function createEnvironmentPresentationAtoms(input: { if (entry === undefined) { return null; } - const state = Option.getOrElse( - AsyncResult.value(get(input.stateAtom(environmentId))), + const state = AsyncResult.getOrElse( + get(input.stateAtom(environmentId)), () => AVAILABLE_CONNECTION_STATE, ); const previous = Option.getOrNull(get.self()); diff --git a/packages/client-runtime/src/state/pullRequests.test.ts b/packages/client-runtime/src/state/pullRequests.test.ts index 966c68dabd9e3..a8627e2d9977e 100644 --- a/packages/client-runtime/src/state/pullRequests.test.ts +++ b/packages/client-runtime/src/state/pullRequests.test.ts @@ -1430,15 +1430,11 @@ it.effect("updates cached labels after successful edits without rereading the ho registry.refresh(detail); yield* detailRefreshStarted.await; expect(registry.get(detail).waiting).toBe(true); - expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ - addedLabel, - ]); + expect(AsyncResult.getOrThrow(registry.get(detail)).labels).toEqual([addedLabel]); yield* releaseDetailRefresh.open; yield* Effect.exit(AtomRegistry.getResult(registry, detail, { suspendOnWaiting: true })); expect(AsyncResult.isFailure(registry.get(detail))).toBe(true); - expect(Option.getOrThrow(AsyncResult.value(registry.get(detail))).labels).toEqual([ - addedLabel, - ]); + expect(AsyncResult.getOrThrow(registry.get(detail)).labels).toEqual([addedLabel]); failDetail = false; registry.refresh(detail); expect( @@ -1562,7 +1558,7 @@ it.effect("publishes the accepted guide run before the follow-up read finishes", const result = yield* Effect.promise(() => atoms.generateReviewGuide.run(registry, target)); expect(result._tag).toBe("Success"); yield* refreshStarted.await; - expect(Option.getOrNull(AsyncResult.value(registry.get(query)))).toEqual(accepted); + expect(AsyncResult.getOrElse(registry.get(query), () => null)).toEqual(accepted); }), ), ); diff --git a/packages/client-runtime/src/state/pullRequests.ts b/packages/client-runtime/src/state/pullRequests.ts index 6b675f36a7858..ca5fb6d7a77d5 100644 --- a/packages/client-runtime/src/state/pullRequests.ts +++ b/packages/client-runtime/src/state/pullRequests.ts @@ -520,7 +520,7 @@ export function createPullRequestEnvironmentAtoms( Effect.sync(() => { const { reviewers, requested } = target.input; const candidatesAtom = reviewerCandidates(target); - const candidates = Option.getOrNull(AsyncResult.value(registry.get(candidatesAtom))); + const candidates = AsyncResult.getOrElse(registry.get(candidatesAtom), () => null); const selected = candidates?.candidates.filter((candidate) => reviewers.some( @@ -606,7 +606,7 @@ export function createPullRequestEnvironmentAtoms( Effect.sync(() => { const { labels, applied } = target.input; const candidatesAtom = labelCandidates(target); - const candidates = Option.getOrNull(AsyncResult.value(registry.get(candidatesAtom))); + const candidates = AsyncResult.getOrElse(registry.get(candidatesAtom), () => null); const names = new Set(labels); updateCached(registry, candidatesAtom, (value) => ({ ...value, diff --git a/packages/client-runtime/src/state/relayDiscovery.ts b/packages/client-runtime/src/state/relayDiscovery.ts index 34b8c662c8821..b31dd67ca9dad 100644 --- a/packages/client-runtime/src/state/relayDiscovery.ts +++ b/packages/client-runtime/src/state/relayDiscovery.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; import { AsyncResult, Atom } from "effect/reactivity"; @@ -19,8 +18,8 @@ export function createRelayEnvironmentDiscoveryAtoms( { initialValue: RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE }, ); const stateValueAtom = Atom.make((get) => - Option.getOrElse( - AsyncResult.value(get(stateAtom)), + AsyncResult.getOrElse( + get(stateAtom), () => RelayEnvironmentDiscovery.EMPTY_RELAY_ENVIRONMENT_DISCOVERY_STATE, ), ).pipe(Atom.withLabel("relay-environment-discovery-value")); diff --git a/packages/client-runtime/src/state/runtime.test.ts b/packages/client-runtime/src/state/runtime.test.ts index bb69334bc7870..963b3a7bfdeb7 100644 --- a/packages/client-runtime/src/state/runtime.test.ts +++ b/packages/client-runtime/src/state/runtime.test.ts @@ -1306,7 +1306,7 @@ describe("persisted environment queries", () => { ); const registry = yield* mountEnvironmentQuery(harness.atom); yield* Effect.result(readEnvironmentQuery(registry, harness.atom)); - expect(Option.getOrNull(AsyncResult.value(registry.get(harness.atom)))).toBe("cached"); + expect(AsyncResult.getOrElse(registry.get(harness.atom), () => null)).toBe("cached"); }), ), ); diff --git a/packages/client-runtime/src/state/runtime.ts b/packages/client-runtime/src/state/runtime.ts index 20054521a778a..fde1790d338a3 100644 --- a/packages/client-runtime/src/state/runtime.ts +++ b/packages/client-runtime/src/state/runtime.ts @@ -558,7 +558,7 @@ const refreshEveryUntilFinal = * reader of only this skips the refetches that changed nothing. */ export const selectEnvironmentQueryData = (result: AsyncResult.AsyncResult): A | null => - Option.getOrNull(AsyncResult.value(result)); + AsyncResult.getOrElse(result, () => null); type EnvironmentConnection = readonly [ SupervisorConnectionState, @@ -628,7 +628,7 @@ export function environmentSessionGenerationAtom( get.self<{ readonly session: RpcSession.RpcSession | null; readonly count: number }>(), () => ({ session: null, count: 0 }), ); - const connection = Option.getOrNull(AsyncResult.value(get(connectionAtom(id)))); + const connection = AsyncResult.getOrElse(get(connectionAtom(id)), () => null); if (connection === null || connection[0].phase !== "connected") return previous; const session = Option.getOrNull(connection[1]); return session === null || session === previous.session @@ -674,8 +674,9 @@ export function createEnvironmentQueryAtomFamily( | EnvironmentRegistry.EnvironmentNotRegisteredError | EnvironmentRpcUnavailableError >((get) => { - const connection = Option.getOrNull( - AsyncResult.value(get(connectionAtom(target.environmentId))), + const connection = AsyncResult.getOrElse( + get(connectionAtom(target.environmentId)), + () => null, ); // A refetch that returns equal content keeps the previous value's // references, so readers of the data skip re-rendering on idle polls. diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 892817d0b8067..3cb4f77325fa6 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -792,8 +792,9 @@ export function createServerEnvironmentAtoms( } let previousProviders: ServerConfig["providers"] | undefined; return Atom.make((get): ServerConfig | null => { - const projection = Option.getOrNull( - AsyncResult.value(get(configProjection({ environmentId, input: {} }))), + const projection = AsyncResult.getOrElse( + get(configProjection({ environmentId, input: {} })), + () => null, ); const config = resolveServerConfigValue( projection, diff --git a/packages/client-runtime/src/state/session.ts b/packages/client-runtime/src/state/session.ts index 0d7963e42e0cb..efdf0b37c0a09 100644 --- a/packages/client-runtime/src/state/session.ts +++ b/packages/client-runtime/src/state/session.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import type { AuthSessionState, EnvironmentId, ServerConfig } from "@t3tools/contracts"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; @@ -104,10 +105,11 @@ function makeEnvironmentSessionAtoms( // createServerEnvironmentAtoms(...).configValueAtom instead. const initialConfigValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get): ServerConfig | null => - Option.getOrNull( - Option.getOrElse(AsyncResult.value(get(initialConfigAtom(environmentId))), () => - Option.none(), - ), + pipe( + get(initialConfigAtom(environmentId)), + AsyncResult.value, + Option.flatten, + Option.getOrNull, ), ).pipe(Atom.withLabel(`environment-config-value:${environmentId}`)), ); @@ -128,7 +130,7 @@ function makeEnvironmentSessionAtoms( const preparedConnectionValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(preparedConnectionAtom(environmentId))), () => + AsyncResult.getOrElse(get(preparedConnectionAtom(environmentId)), () => Option.none(), ), ).pipe(Atom.withLabel(`environment-prepared-connection:${environmentId}`)), @@ -165,7 +167,7 @@ function makeEnvironmentSessionAtoms( const sessionStateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make( (get): AuthSessionState | null => - Option.getOrNull(AsyncResult.value(get(sessionStateAtom(environmentId)))) ?? null, + AsyncResult.getOrElse(get(sessionStateAtom(environmentId)), () => null) ?? null, ).pipe(Atom.withLabel(`environment-session-state-value:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/shell.ts b/packages/client-runtime/src/state/shell.ts index 1a396cf8c74f6..bce13d315b425 100644 --- a/packages/client-runtime/src/state/shell.ts +++ b/packages/client-runtime/src/state/shell.ts @@ -596,7 +596,7 @@ export function createEnvironmentShellAtoms( const stateValueAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => - Option.getOrElse(AsyncResult.value(get(stateAtom(environmentId))), () => EMPTY_SHELL_STATE), + AsyncResult.getOrElse(get(stateAtom(environmentId)), () => EMPTY_SHELL_STATE), ).pipe(Atom.withLabel(`environment-shell-state-value:${environmentId}`)), ); diff --git a/packages/client-runtime/src/state/terminal.ts b/packages/client-runtime/src/state/terminal.ts index a86579edcb339..a39e47b1340b4 100644 --- a/packages/client-runtime/src/state/terminal.ts +++ b/packages/client-runtime/src/state/terminal.ts @@ -10,7 +10,6 @@ import { WS_METHODS, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Stream from "effect/Stream"; import { AsyncResult, Atom, type AtomRegistry } from "effect/reactivity"; @@ -323,7 +322,7 @@ export function createTerminalSession( result: AsyncResult.AsyncResult, ) => { resizeRegistry = registry; - const buffer = Option.getOrElse(AsyncResult.value(result), () => EMPTY_TERMINAL_BUFFER_STATE); + const buffer = AsyncResult.getOrElse(result, () => EMPTY_TERMINAL_BUFFER_STATE); const status = result._tag === "Failure" ? "error" : buffer.status; if (status !== lastStatus) { lastStatus = status; diff --git a/packages/client-runtime/src/state/threadDetail.ts b/packages/client-runtime/src/state/threadDetail.ts index a53494ebc5929..0c93800378797 100644 --- a/packages/client-runtime/src/state/threadDetail.ts +++ b/packages/client-runtime/src/state/threadDetail.ts @@ -199,8 +199,8 @@ export function createEnvironmentThreadDetailAtoms( const threadStateValueAtomFamily = Atom.family((key: string) => { const ref = parseThreadKey(key); return Atom.make((get) => - Option.getOrElse( - AsyncResult.value(get(threadStateAtom(ref.environmentId, ref.threadId))), + AsyncResult.getOrElse( + get(threadStateAtom(ref.environmentId, ref.threadId)), () => EMPTY_ENVIRONMENT_THREAD_STATE, ), ).pipe(Atom.setIdleTTL(0), Atom.withLabel(`environment-thread-state-value:${key}`)); diff --git a/packages/client-runtime/src/state/threadFamilies.ts b/packages/client-runtime/src/state/threadFamilies.ts index 0f40ac1939461..0f1bb8d9cd274 100644 --- a/packages/client-runtime/src/state/threadFamilies.ts +++ b/packages/client-runtime/src/state/threadFamilies.ts @@ -78,7 +78,7 @@ export function createThreadFamilyAtoms( ) => Atom.Atom>, ) { const shellStateValue = (state: AsyncResult.AsyncResult) => - Option.getOrNull(AsyncResult.value(state)); + AsyncResult.getOrElse(state, () => null); const generationAtom = Atom.family((environmentId: EnvironmentId) => Atom.make((get) => shellStateValue(get(shellStateAtom(environmentId)))?.generation ?? 0).pipe( Atom.withLabel(`environment-shell-generation:${environmentId}`), diff --git a/packages/client-runtime/src/state/threadSearch.ts b/packages/client-runtime/src/state/threadSearch.ts index 90a561f8cf783..caaebb016ecd4 100644 --- a/packages/client-runtime/src/state/threadSearch.ts +++ b/packages/client-runtime/src/state/threadSearch.ts @@ -150,7 +150,7 @@ export function createThreadSearchResultsAtomFamily(options: { }), ); isLoading ||= result.waiting; - const value = Option.getOrNull(AsyncResult.value(result)); + const value = AsyncResult.getOrElse(result, () => null); if (value !== null) { matches.push(...value.matches.map((match) => ({ ...match, environmentId }))); if (value.threads !== undefined && value.threads.length > 0) { diff --git a/packages/effect-codex-app-server/scripts/generate.ts b/packages/effect-codex-app-server/scripts/generate.ts index 6b1c8155830b3..23ce2e1edd360 100644 --- a/packages/effect-codex-app-server/scripts/generate.ts +++ b/packages/effect-codex-app-server/scripts/generate.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { pipe } from "effect/Function"; import * as NodeRuntime from "@effect/platform-node/NodeRuntime"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { make as makeJsonSchemaGenerator } from "@effect/openapi-generator/JsonSchemaGenerator"; @@ -636,15 +637,15 @@ const generateFiles = Effect.fn("generateFiles")(function* () { ); for (const [definitionName, definitionSchema] of Object.entries(parsed.definitions ?? {})) { - aggregateSchemas[localDefinitionNames.get(definitionName)!] = stripNullDefaults( - normalizeNullableTypes( - rewriteExternalRefs( - DefinitionOverrides[definitionName] ?? definitionSchema, - localDefinitionNames, - file.namespace, - exportNameByQualifiedName, - ), + aggregateSchemas[localDefinitionNames.get(definitionName)!] = pipe( + rewriteExternalRefs( + DefinitionOverrides[definitionName] ?? definitionSchema, + localDefinitionNames, + file.namespace, + exportNameByQualifiedName, ), + normalizeNullableTypes, + stripNullDefaults, ); } @@ -655,15 +656,15 @@ const generateFiles = Effect.fn("generateFiles")(function* () { } } - aggregateSchemas[file.exportName] = stripNullDefaults( - normalizeNullableTypes( - rewriteExternalRefs( - topLevelSchema, - localDefinitionNames, - file.namespace, - exportNameByQualifiedName, - ), + aggregateSchemas[file.exportName] = pipe( + rewriteExternalRefs( + topLevelSchema, + localDefinitionNames, + file.namespace, + exportNameByQualifiedName, ), + normalizeNullableTypes, + stripNullDefaults, ); } diff --git a/packages/effect-codex-app-server/src/replay.ts b/packages/effect-codex-app-server/src/replay.ts index cd99a4bb859e3..11ef80c02f5bf 100644 --- a/packages/effect-codex-app-server/src/replay.ts +++ b/packages/effect-codex-app-server/src/replay.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import * as Cause from "effect/Cause"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -269,7 +270,11 @@ function sameFrame( ignoredConfigKeys: ReadonlySet = new Set(), ): boolean { const normalize = (frame: unknown) => - stableStringify(normalizeThreadRequest(normalizeReplayFrame(frame), ignoredConfigKeys)); + pipe( + normalizeReplayFrame(frame), + (request) => normalizeThreadRequest(request, ignoredConfigKeys), + stableStringify, + ); return normalize(left) === normalize(right); } diff --git a/packages/provider-core/src/server/handoffBudget.ts b/packages/provider-core/src/server/handoffBudget.ts index 06adb39b44689..4658bc44fc6d3 100644 --- a/packages/provider-core/src/server/handoffBudget.ts +++ b/packages/provider-core/src/server/handoffBudget.ts @@ -197,14 +197,16 @@ export function renderHistory( return [context, ...messages.map(renderHistoricalMessage)].join("\n\n"); } +const jsonByteLength = (value: unknown) => Buffer.byteLength(JSON.stringify(value)); + export function historyCost( messages: ReadonlyArray, context: string, ): number { return ( Math.max( - Buffer.byteLength(JSON.stringify(historyResponseItems(messages, context))), - Buffer.byteLength(JSON.stringify(renderHistory(messages, context))), + jsonByteLength(historyResponseItems(messages, context)), + jsonByteLength(renderHistory(messages, context)), ) + 256 ); } diff --git a/packages/shared/src/dpop.ts b/packages/shared/src/dpop.ts index 5fe9db02904fb..3e0a22128d3c5 100644 --- a/packages/shared/src/dpop.ts +++ b/packages/shared/src/dpop.ts @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { p256 } from "@noble/curves/nist"; import { sha256 } from "@noble/hashes/sha2"; import * as Base64Url from "effect/encoding/Base64Url"; @@ -94,7 +95,12 @@ function dpopThumbprintInput(jwk: DpopPublicJwkType): string { } export function computeDpopJwkThumbprint(jwk: DpopPublicJwkType): string { - return Base64Url.encode(sha256(new TextEncoder().encode(dpopThumbprintInput(jwk)))); + return pipe( + dpopThumbprintInput(jwk), + (input) => new TextEncoder().encode(input), + sha256, + Base64Url.encode, + ); } export function computeDpopAccessTokenHash(accessToken: string): string { diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index ff8897efe5313..aa2f285ebd12c 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -1,4 +1,5 @@ // @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists. +import { pipe } from "effect/Function"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -19,8 +20,11 @@ export interface T3CodePublicConfig { type Environment = Readonly>; -const REPO_ROOT = NodePath.dirname( - NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))), +const REPO_ROOT = pipe( + NodeURL.fileURLToPath(import.meta.url), + NodePath.dirname, + NodePath.dirname, + NodePath.dirname, ); export function loadRepoEnv({ From 10e709c33786682217ad3c4f9226ba6c13e5edd7 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 03:20:15 -0500 Subject: [PATCH 205/317] fix(queued-message): avoid duplicate queued message entries (#26355) --- apps/mobile/src/lib/threadActivity.ts | 6 ++ .../src/orchestration-v2/Orchestrator.ts | 4 + .../SteeringCompletion.integration.test.ts | 87 +++++++++++++++++++ apps/web/src/session-logic.test.ts | 21 +++++ apps/web/src/session-logic.ts | 6 ++ 5 files changed, 124 insertions(+) diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 03ea87da79672..9cdb3b6f8d815 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -2191,6 +2191,12 @@ export function buildThreadFeed( const item = row.item; if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; + if ( + item.type === "user_message" && + item.inputIntent === "queued_turn" && + item.status === "pending" + ) + continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; if ( item.type === "run_interrupt_request" || diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index fa447ef165531..3f76bd6185eb7 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -1920,6 +1920,7 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio }), providerThreadId: queuedProviderThread.id, inputIntent: "queued_turn", + status: "completed", startedAt: now, completedAt: now, updatedAt: now, @@ -5749,6 +5750,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio providerThreadId: queuedProviderThread.id, providerTurnId: null, inputIntent: "queued_turn", + status: "pending", + startedAt: null, + completedAt: null, updatedAt: now, }, }); diff --git a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts index 7481dcaa41b2c..d8ac4769e140f 100644 --- a/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts +++ b/apps/server/src/orchestration-v2/SteeringCompletion.integration.test.ts @@ -339,6 +339,13 @@ it.effect.each( if (delivery !== null) { yield* Deferred.succeed(rejectSteer, undefined); yield* Fiber.join(delivery); + const requeued = yield* orchestrator.getThreadProjection(threadId); + const item = requeued.turnItems.find( + (item) => item.type === "user_message" && item.messageId === messageId, + ); + assert.equal(item?.status, "pending"); + assert.isNull(item?.startedAt); + assert.isNull(item?.completedAt); } if (timing === "before dispatch") yield* dispatchSteer; yield* worker.drain(); @@ -391,6 +398,14 @@ it.effect.each( ]); assert.equal(steerCalls, timing === "during delivery" ? 1 : 0); const final = yield* orchestrator.getThreadProjection(threadId); + if (!mailbox) { + assert.equal( + final.turnItems.find( + (item) => item.type === "user_message" && item.messageId === messageId, + )?.status, + "completed", + ); + } assert.equal(final.messages.filter((message) => message.id === messageId).length, 1); assert.equal( final.messages.find((message) => message.id === messageId)?.runId, @@ -669,6 +684,78 @@ it.effect("sends a selected held message once and preserves the remaining queue ), ); +it.effect("force sends multiple queued messages and can force send a requeued message again", () => + Effect.scoped( + Effect.gen(function* () { + const { started, steered, layer, startFirstTurn } = yield* nextTurnSelectionHarness( + "multiple-promoted-messages", + ); + yield* Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const worker = yield* EffectWorker.OrchestrationEffectWorkerV2; + const threadId = yield* startFirstTurn; + const queue = (text: string, commandId: string) => + orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make(commandId), + threadId, + messageId: MessageId.make(`message:${text}`), + text, + attachments: [], + dispatchMode: { type: "queue_after_active" }, + createdBy: "user", + creationSource: "web", + }); + for (const text of ["one", "two", "remaining"]) yield* queue(text, `queue-${text}`); + const promote = Effect.fn("test.promote")(function* (text: string, commandId: string) { + const projection = yield* orchestrator.getThreadProjection(threadId); + const queued = projection.runs.find( + (run) => + run.status === "queued" && run.userMessageId === MessageId.make(`message:${text}`), + )!; + yield* orchestrator.dispatch({ + type: "queued-message.promote-to-steer", + commandId: CommandId.make(commandId), + threadId, + queuedRunId: queued.id, + targetRunId: started[0]!.runId, + }); + }); + yield* promote("one", "promote-one"); + yield* promote("two", "promote-two"); + yield* worker.drain(); + assert.deepEqual(steered, ["one", "two"]); + + // The completion-race fallback redispatches the same message into the queue. + yield* queue("one", "requeue-one"); + const requeued = yield* orchestrator.getThreadProjection(threadId); + assert.equal( + requeued.turnItems.find( + (item) => item.type === "user_message" && item.messageId === "message:one", + )?.status, + "pending", + ); + yield* promote("one", "promote-one-again"); + yield* worker.drain(); + assert.deepEqual(steered, ["one", "two", "one"]); + assert.lengthOf(started, 1); + const final = yield* orchestrator.getThreadProjection(threadId); + assert.deepEqual( + final.runs.filter((run) => run.status === "queued").map((run) => run.userMessageId), + [MessageId.make("message:remaining")], + ); + for (const text of ["one", "two"]) { + const items = final.turnItems.filter( + (item) => item.type === "user_message" && item.messageId === `message:${text}`, + ); + assert.lengthOf(items, 1); + assert.equal(items[0]?.status, "completed"); + } + }).pipe(Effect.provide(layer)); + }), + ), +); + it.effect("keeps a message queued when send now races with a running turn", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index fd10c96aa1b24..de213de2f3c1c 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -672,6 +672,27 @@ describe("V2 session presentation", () => { scheduledTaskId: ScheduledTaskId.make("task-queued"), senderThreadId: ThreadId.make("thread-agent-sender"), } satisfies OrchestrationV2TurnItem; + for (const inputIntent of [ + "queued_turn", + "turn_start", + "steer", + "promoted_queued_to_steer", + ] as const) { + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + position: 0, + visibility: "local", + sourceThreadId: projection.thread.id, + sourceItemId: dispatchedItem.id, + item: { ...dispatchedItem, inputIntent, status: "pending" }, + }, + ], + optimisticMessages: [], + }), + ).toHaveLength(inputIntent === "queued_turn" ? 0 : 1); + } const promotedEntries = deriveTimelineEntriesFromVisibleTurnItems({ visibleTurnItems: [ { diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index eb61b244075a3..362d9fb9451a4 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -603,6 +603,12 @@ export function deriveTimelineEntriesFromVisibleTurnItems( const { item } = row; if (turnItemIsWorkspacePreparation(item)) continue; if (item.type === "checkpoint") continue; + if ( + item.type === "user_message" && + item.inputIntent === "queued_turn" && + item.status === "pending" + ) + continue; if (item.type === "user_message" && foldedAnswerMessageIds.has(item.messageId)) continue; const createdAt = projectedItemCreatedAt(row); const attempt = resolveAttempt(item); From f17c9d202b38ea9a009a2d6dc3932e212bad4c24 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 01:28:02 -0700 Subject: [PATCH 206/317] fix(models): new Claude threads default to Opus 5.5 (#26358) Co-authored-by: Claude Opus 5.5 --- apps/server/src/provider/model-manifest.json | 4 ++-- packages/contracts/src/model.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 444c130c1ee11..b42932fcc6668 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -1,6 +1,6 @@ { "version": 1, - "updatedAt": "2026-10-27T23:47:46Z", + "updatedAt": "2026-10-28T08:10:39Z", "compatibility": [ { "driver": "codex", @@ -150,7 +150,7 @@ }, "claudeAgent": { "defaults": { - "chat": "claude-fable-5-1" + "chat": "claude-opus-5-5" }, "profiles": { "fable-5": { diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index f27d977767024..8027c90799dbc 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -140,7 +140,7 @@ export const MUSE_DEFAULT_MODEL = "default"; export const DEFAULT_MODEL_BY_PROVIDER: Partial> = { [CODEX_DRIVER_KIND]: DEFAULT_MODEL, - [CLAUDE_DRIVER_KIND]: "claude-fable-5-1", + [CLAUDE_DRIVER_KIND]: "claude-opus-5-5", [CURSOR_DRIVER_KIND]: "auto", // Product slug meaning the current session model, never an ACP model id. [GROK_DRIVER_KIND]: "grok-build", From a4e1986585771e80928cc50720a2532c52071d01 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 01:46:46 -0700 Subject: [PATCH 207/317] docs: T3 Chat Pro and Premier raise the T3 Connect tunnel limit (#26366) Co-authored-by: Claude Opus 5.5 --- docs/user/faq.md | 6 +++++- docs/user/remote-access.md | 12 ++++++++++++ docs/user/t3-connect-troubleshooting.md | 6 ++++-- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/user/faq.md b/docs/user/faq.md index 354d0c63c3164..bb4713b5f25e8 100644 --- a/docs/user/faq.md +++ b/docs/user/faq.md @@ -25,7 +25,11 @@ Yes. ## How much does T3 Code cost? -Nothing. There's no way to pay us to use T3 Code. You bring your own sub. It is free and open source (MIT). You still pay your provider like you already do. T3 Connect is free too, for now. Every account can connect up to 3 machines running T3 Code. Phones and browsers that connect to them do not count. We don't want to charge for this, but if the bill gets unacceptable we may have to change course. If you hit limits or have issues, you can always fork it, self host, or use Tailscale. [Install](./install.md) +Nothing. There's no way to pay us to use T3 Code. You bring your own sub. It is free and open source (MIT). You still pay your provider like you already do. T3 Connect is free too, for now. Every account can connect up to 3 machines running T3 Code. Phones and browsers that connect to them do not count. Need more? Link a paid T3 Chat account. [More machines](#can-i-connect-more-than-3-machines-with-t3-connect) We don't want to charge for this, but if the bill gets unacceptable we may have to change course. If you hit limits or have issues, you can always fork it, self host, or use Tailscale. [Install](./install.md) + +## Can I connect more than 3 machines with T3 Connect? + +Yes. Link your T3 Chat account and T3 Connect raises your limit: up to 10 machines with T3 Chat Pro, or 100 with Premier. Open your account menu in T3 Code, go to **T3 Chat**, and select **Link T3 Chat**. Sign in to T3 Chat and approve. The free T3 Chat plan keeps the normal limit of 3. [More machines with T3 Chat](./remote-access.md#more-machines-with-t3-chat) ## Why is my Claude 5-hour limit draining so fast? diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index 835476864a1fc..b8f9553d95beb 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -32,6 +32,18 @@ connection. Pull request diffs and provider settings keep working after the previous credential expires. A failed renewal affects that request; it does not disconnect an otherwise healthy conversation. +### More machines with T3 Chat + +Each T3 Connect account can link up to 3 machines. Phones and browsers do not +count. A paid T3 Chat plan raises the limit to 10 machines with Pro, or 100 with +Premier. + +To link T3 Chat, open your account menu on the web or desktop app, go to +**T3 Chat**, and select **Link T3 Chat**. Sign in to T3 Chat in the window that +opens and approve. The new limit applies the next time you add a machine. If you +cancel or downgrade T3 Chat, machines you already linked stay linked, but you +cannot add more until you are under the limit again. + ## Pair over a LAN or private network Use direct pairing when the other device can reach the host's network address. diff --git a/docs/user/t3-connect-troubleshooting.md b/docs/user/t3-connect-troubleshooting.md index eaed689fb8c62..8c507a58e6894 100644 --- a/docs/user/t3-connect-troubleshooting.md +++ b/docs/user/t3-connect-troubleshooting.md @@ -112,7 +112,9 @@ T3 Connect does not have an active link between your account and the host. - **This device's sign-in expired, or the host no longer accepts this device.** Sign out of T3 Connect and sign in again. - **Your account reached its tunnel limit.** Remove an environment you do not - use from your T3 account's T3 Connect page, then restart T3 Code on the host. + use from your T3 account's T3 Connect page, or + [link a paid T3 Chat account](./remote-access.md#more-machines-with-t3-chat) + for a higher limit. Then restart T3 Code on the host. ## The clock is off @@ -129,7 +131,7 @@ This app needs a newer T3 Code on the host. Update T3 Code on the host. See | Error | Fix | | --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | -| `environment_link_limit_exceeded` or managed tunnel limit | Remove an unused environment, then restart T3 Code on the host. | +| `environment_link_limit_exceeded` or managed tunnel limit | Remove an unused environment or link a paid T3 Chat account, then restart T3 Code on the host. | | `auth_invalid` or `invalid_bearer` | Run `t3 connect login`. If credentials were revoked, run `t3 connect logout`, then `t3 connect` again. Restart the server after signing in. | | Expired or invalid link proof | Check the host's date and time, update T3 Code, then restart it. | | HTTP 403 without a recognized error | Check relay access, proxies, and firewall rules. Keep any Cloudflare Ray ID for a bug report. | From dac58fa7a6f79d9d0a8053a3c0e2e43f2997e04d Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:20:37 +0530 Subject: [PATCH 208/317] fix(web): use neutral indicators for inactive source control providers (#22778) --- .../src/components/settings/SourceControlSettings.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/SourceControlSettings.tsx b/apps/web/src/components/settings/SourceControlSettings.tsx index 7f7b0a90ebcc5..b519f7322c7d0 100644 --- a/apps/web/src/components/settings/SourceControlSettings.tsx +++ b/apps/web/src/components/settings/SourceControlSettings.tsx @@ -74,13 +74,13 @@ function isVcsNotReady(item: VcsDiscoveryItem | SourceControlProviderDiscoveryIt function authPresentation(auth: SourceControlProviderAuth): { readonly label: string; - readonly badge: "warning" | null; + readonly badge: "secondary" | null; } { if (auth.status === "authenticated") { return { label: "Authenticated", badge: null }; } if (auth.status === "unauthenticated") { - return { label: "Not authenticated", badge: "warning" }; + return { label: "Not authenticated", badge: "secondary" }; } return { label: "Status unknown", badge: null }; } @@ -98,8 +98,12 @@ function RedactedAccount(props: { readonly account: string | null }) { function itemStatusDot(item: VcsDiscoveryItem | SourceControlProviderDiscoveryItem): string { if (isVcsNotReady(item)) return "bg-muted-foreground/35"; + if (isProviderDiscoveryItem(item)) { + return item.status === "available" && item.auth.status === "authenticated" + ? "bg-success" + : "bg-muted-foreground/35"; + } if (item.status !== "available") return "bg-warning"; - if (isProviderDiscoveryItem(item) && item.auth.status !== "authenticated") return "bg-warning"; return "bg-success"; } From cb4fe60a4b5a6598c0f73680bf242f5b85bb5279 Mon Sep 17 00:00:00 2001 From: Utkarsh Patil <73941998+UtkarshUsername@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:21:06 +0530 Subject: [PATCH 209/317] fix(web): show the thread details tooltip (#24864) --- .../components/chat/PanelLayoutControls.tsx | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/chat/PanelLayoutControls.tsx b/apps/web/src/components/chat/PanelLayoutControls.tsx index 11d22b1354e10..6750298402942 100644 --- a/apps/web/src/components/chat/PanelLayoutControls.tsx +++ b/apps/web/src/components/chat/PanelLayoutControls.tsx @@ -1,7 +1,7 @@ import { PanelBottomIcon, PanelRightIcon, SquareMenuIcon } from "lucide-react"; import { Maximize2, Minimize2 } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; -import { memo, type ReactElement, type ReactNode } from "react"; +import { memo, type ReactNode } from "react"; import { cn } from "~/lib/utils"; @@ -53,6 +53,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ ); - const threadPanelTooltip = (trigger: ReactElement) => ( + const threadPanelControl = ( - + }> + {threadPanelPresentation === "popover" ? ( + + ) : ( + threadPanelToggle + )} + Toggle thread details {threadPanelShortcutLabel ? ` (${threadPanelShortcutLabel})` : ""} @@ -78,13 +82,7 @@ export const PanelLayoutControls = memo(function PanelLayoutControls({ className="flex h-full shrink-0 items-center gap-1 [-webkit-app-region:no-drag]" data-panel-layout-controls > - {showThreadPanelControl - ? threadPanelPresentation === "popover" - ? threadPanelTooltip( - , - ) - : threadPanelTooltip(threadPanelToggle) - : null} + {showThreadPanelControl ? threadPanelControl : null} {showTerminalControl ? ( }> From 975b3dc56850d8a22dc90462219e53be95d8dcf7 Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 03:52:05 -0500 Subject: [PATCH 210/317] fix(client-runtime): keep subagent questions off parent threads (#26367) --- .../client-runtime/src/state/threadShell.test.ts | 16 +++++++++++++++- packages/client-runtime/src/state/threadShell.ts | 4 +++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/client-runtime/src/state/threadShell.test.ts b/packages/client-runtime/src/state/threadShell.test.ts index ec40d5f679ec9..fda1054064f78 100644 --- a/packages/client-runtime/src/state/threadShell.test.ts +++ b/packages/client-runtime/src/state/threadShell.test.ts @@ -108,7 +108,7 @@ describe("v2 thread shell lists", () => { registry.dispose(); }); - it("counts a subagent question on its parent only while the subagent runs", () => { + it("keeps a live subagent question inside the parent without flagging its thread", () => { const { registry, threads, snapshotAtom } = makeHarness(); const root = v2ThreadShell; const child = { @@ -132,6 +132,10 @@ describe("v2 thread shell lists", () => { const dispose = registry.mount(inputs); registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, child] }); expect(registry.get(inputs).map((thread) => thread.id)).toEqual([child.id]); + expect( + registry.get(threads.threadShellAtom({ environmentId, threadId: root.id })) + ?.pendingSubagentRequest, + ).toBeNull(); registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [root, { ...child, status: "queued" as const }], @@ -272,6 +276,16 @@ describe("v2 thread shell lists", () => { `${remoteEnvironmentId}/${helper.id}`, ]); + registry.set(snapshotAtom(remoteEnvironmentId), { + ...v2ShellSnapshot, + threads: [ + task, + { ...helper, pendingRuntimeRequest: { ...approval, kind: "user_input" as const } }, + ], + }); + expect(registry.get(threads.threadShellAtom(parentRef))?.pendingSubagentRequest).toBeNull(); + expect(registry.get(inputs).map((thread) => thread.id)).toEqual([helper.id]); + // A task whose parent is gone there is listed on its own, as is one whose // parent's environment is switched off. registry.set(snapshotAtom(environmentId), { ...v2ShellSnapshot, threads: [] }); diff --git a/packages/client-runtime/src/state/threadShell.ts b/packages/client-runtime/src/state/threadShell.ts index 69df72418c2d0..15718da1eca65 100644 --- a/packages/client-runtime/src/state/threadShell.ts +++ b/packages/client-runtime/src/state/threadShell.ts @@ -76,7 +76,7 @@ function subagentRequest(thread: OrchestrationV2ThreadShell): PendingSubagentReq } /** - * The threads to flag for a subagent's live question or approval, up its + * The threads to flag for a subagent's live approval, up its * subagent chain. `delegated` are tasks this environment's threads delegated * to linked environments, with what each one, or its own subagents, asks. */ @@ -94,6 +94,8 @@ function pendingSubagentRequestsByThread( request: PendingSubagentRequest, seen: Set, ) => { + // The coordinator answers child questions; only approvals need user attention. + if (request === "user_input") return; byId ??= new Map(threads.map((thread) => [thread.id, thread] as const)); let parentId = firstParentId; while (parentId !== null && !seen.has(parentId)) { From be2ea30d2b269f1526be3fd556bc5380b4dc01b3 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Wed, 28 Oct 2026 02:01:37 -0700 Subject: [PATCH 211/317] fix(build): keep effect out of the public-config bootstrap (#26371) Co-authored-by: Claude Opus 5.5 --- scripts/lib/public-config.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/scripts/lib/public-config.ts b/scripts/lib/public-config.ts index aa2f285ebd12c..7fe8d05386cd1 100644 --- a/scripts/lib/public-config.ts +++ b/scripts/lib/public-config.ts @@ -1,5 +1,4 @@ // @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists. -import { pipe } from "effect/Function"; import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeURL from "node:url"; @@ -20,12 +19,7 @@ export interface T3CodePublicConfig { type Environment = Readonly>; -const REPO_ROOT = pipe( - NodeURL.fileURLToPath(import.meta.url), - NodePath.dirname, - NodePath.dirname, - NodePath.dirname, -); +const REPO_ROOT = NodePath.resolve(NodeURL.fileURLToPath(import.meta.url), "../../.."); export function loadRepoEnv({ baseEnv = process.env, From 4c9c7d0bb65765232bbb41f83d3fd17ec5e3af66 Mon Sep 17 00:00:00 2001 From: "t3-code-bot[bot]" <269035359+t3-code-bot[bot]@users.noreply.github.com> Date: Wed, 28 Oct 2026 09:07:37 +0000 Subject: [PATCH 212/317] chore(mobile): bump app version to 2.2.0 Co-authored-by: codex --- apps/mobile/app.config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index 6ea260eae4d9e..ea885f819984f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -226,7 +226,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.1.0", + version: repoEnv.T3CODE_MOBILE_VERSION ?? "2.2.0", runtimeVersion: { // Skip costly fingerprinting on dev launches; preview and production OTAs require matching native projects. policy: runtimeVersionPolicy, From c677d5fc45e6d585e52bca03244a0870c588a555 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 02:22:53 -0700 Subject: [PATCH 213/317] fix(web): scroll-to-end button no longer jumps up and down (#26375) Co-authored-by: Claude Opus 5.5 --- .../chat/MessagesTimeline.browser.test.tsx | 23 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 6 +++-- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx index 2c7aa332756cb..085fa23b2c49d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.browser.test.tsx @@ -205,6 +205,29 @@ describe("MessagesTimeline messages", () => { expect(maxNestedCommits).toBeLessThan(20); }); + it("keeps reporting overflow while the composer inset changes", async () => { + setup(); + const props = baseProps(); + const overflowReports: boolean[] = []; + const replies = Array.from({ length: 40 }, (_, index) => + message(`Reply ${index}\n\n${"line\n\n".repeat(8)}`, { role: "assistant" }, `r-${index}`), + ); + const timeline = (inset: number) => ( + overflowReports.push(overflows)} + /> + ); + await using screen = await renderElement(timeline(110)); + await vi.waitFor(() => expect(overflowReports.at(-1)).toBe(true)); + overflowReports.length = 0; + // The resting composer resizes with overflow; a false report here flips it back. + await screen.rerender(timeline(148)); + expect(overflowReports).not.toContain(false); + }); + it("keeps the first and last message headings reachable from either end", async () => { setup(); const props = baseProps(); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c7a38de8fbed6..b74f9edd595c9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1144,7 +1144,9 @@ const ConversationTimeline = memo(function ConversationTimeline({ const canvas = useChatCanvas(); const registerTimeline = canvas?.registerTimeline; - const setTimelineList = (list: LegendListRef | null) => { + // Stable so the list attaches once per mount. A re-attach reports no overflow for a frame, + // which flips the resting composer, resizes it, and re-renders this list again. + const setTimelineList = useStableCallback((list: LegendListRef | null) => { listRef.current = list; const scrollNode: HTMLElement | null = list?.getScrollableNode() ?? null; registerTimeline?.(scrollNode); @@ -1190,7 +1192,7 @@ const ConversationTimeline = memo(function ConversationTimeline({ listRef.current = null; registerTimeline?.(null); }; - }; + }); useThreadFindPositionReader({ threadKey: listIdentityKey, From 07f7f976097b56450364fe136aac9253f4f7d8e1 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 02:45:20 -0700 Subject: [PATCH 214/317] ci(release): releases stop depending on npm and nodejs.org when nothing changed (#26362) Co-authored-by: Claude Opus 5.5 --- .github/actions/setup-ci-deps/action.yml | 20 +++++++++ .github/workflows/release-desktop.yml | 53 ++++++++++++++++++++---- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/.github/actions/setup-ci-deps/action.yml b/.github/actions/setup-ci-deps/action.yml index e97365e2f3ec5..e9b7ae70a36ad 100644 --- a/.github/actions/setup-ci-deps/action.yml +++ b/.github/actions/setup-ci-deps/action.yml @@ -70,6 +70,9 @@ runs: INSTALL_ARGS: ${{ inputs.install-args }} run: | echo "path=$(vp pm cache dir)" >> "$GITHUB_OUTPUT" + metadata_dir="${pnpm_config_cache_dir:-$RUNNER_TEMP/pnpm-metadata}" + echo "pnpm_config_cache_dir=$metadata_dir" >> "$GITHUB_ENV" + echo "verified=$metadata_dir/lockfile-verified.jsonl" >> "$GITHUB_OUTPUT" if [[ -z "$INSTALL_ARGS" ]]; then echo "namespace=full" >> "$GITHUB_OUTPUT" else @@ -94,6 +97,16 @@ runs: pnpm-${{ steps.pnpm_store.outputs.namespace }}-v1-${{ runner.os }}-${{ runner.arch }}- pnpm-full-v1-${{ runner.os }}-${{ runner.arch }}- + # pnpm accepts an earlier pass for the same lockfile and policy at any age, so + # a cached record skips fetching registry metadata for every package. + - name: Restore lockfile verification + id: pnpm_verified + if: inputs.install == 'true' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: pnpm-verified-v1-${{ hashFiles('pnpm-lock.yaml', 'pnpm-workspace.yaml', 'package.json') }} + - name: Install dependencies if: inputs.install == 'true' shell: bash @@ -102,6 +115,13 @@ runs: # Unquoted so each filter is its own argument. run: vp install $INSTALL_ARGS + - name: Save lockfile verification + if: inputs.install == 'true' && steps.pnpm_verified.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ steps.pnpm_store.outputs.verified }} + key: ${{ steps.pnpm_verified.outputs.cache-primary-key }} + # A filtered install that started from the current full store adds nothing # worth a second copy of it. - name: Save pnpm store diff --git a/.github/workflows/release-desktop.yml b/.github/workflows/release-desktop.yml index 3383bb0e80bb8..359070e5482fb 100644 --- a/.github/workflows/release-desktop.yml +++ b/.github/workflows/release-desktop.yml @@ -254,21 +254,60 @@ jobs: if [[ "${{ inputs.version }}" =~ -nightly\.[0-9]{8}\.[0-9]+$ ]]; then brand=nightly; fi node scripts/apply-web-brand-assets.ts "$brand" apps/server/dist/client + - name: Resolve target Node.js for the CLI executable + id: target_node + if: inputs.cli_archive && inputs.platform == 'win' + shell: bash + run: echo "version=$(node -p 'require("./package.json").engines.node')" >> "$GITHUB_OUTPUT" + + # The download only changes with engines.node, so a cached copy keeps + # releases off nodejs.org, which stalls on the runners some days. + - name: Restore target Node.js for the CLI executable + id: target_node_cache + if: inputs.cli_archive && inputs.platform == 'win' + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: node-download-v${{ steps.target_node.outputs.version }}-win-${{ inputs.arch }} + # tsdown downloads the target Node with one fetch and no retry, and # nodejs.org drops that download often enough to fail releases. Seed - # tsdown's cache with a retried, checksum-verified copy instead. + # tsdown's cache with a retried, checksum-verified copy instead. A transfer + # under 100 KB/s for 30s counts as failed, so a stall retries. - name: Download target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} + run: | + set -euo pipefail + name="node-v$VERSION-win-${{ inputs.arch }}" + dir="$RUNNER_TEMP/node-download" + mkdir -p "$dir" + fetch=(curl -fsSL --retry 5 --retry-all-errors --connect-timeout 10 --speed-limit 102400 --speed-time 30) + "${fetch[@]}" -o "$dir/$name.zip" "https://nodejs.org/dist/v$VERSION/$name.zip" + "${fetch[@]}" -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$VERSION/SHASUMS256.txt" + (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) + + # Saved right away so a later failure in this job still keeps the download. + - name: Save target Node.js for the CLI executable + if: inputs.cli_archive && inputs.platform == 'win' && steps.target_node_cache.outputs.cache-hit != 'true' + uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 + with: + path: ${{ runner.temp }}/node-download + key: ${{ steps.target_node_cache.outputs.cache-primary-key }} + + - name: Install target Node.js for the CLI executable if: inputs.cli_archive && inputs.platform == 'win' shell: bash + env: + VERSION: ${{ steps.target_node.outputs.version }} run: | set -euo pipefail - version="$(node -p 'require("./package.json").engines.node')" - name="node-v$version-win-${{ inputs.arch }}" - dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$version/win-${{ inputs.arch }}" + name="node-v$VERSION-win-${{ inputs.arch }}" dir="$RUNNER_TEMP/node-download" - mkdir -p "$dir" "$dest" - curl -fsSL --retry 5 --retry-all-errors -o "$dir/$name.zip" "https://nodejs.org/dist/v$version/$name.zip" - curl -fsSL --retry 5 --retry-all-errors -o "$dir/SHASUMS256.txt" "https://nodejs.org/dist/v$version/SHASUMS256.txt" + dest="${XDG_CACHE_HOME:-$HOME/.cache}/tsdown/node/v$VERSION/win-${{ inputs.arch }}" + mkdir -p "$dest" (cd "$dir" && grep " $name.zip\$" SHASUMS256.txt | sha256sum -c -) unzip -p "$dir/$name.zip" "$name/node.exe" > "$dest/node.exe" From 001d16e9699ad22863f065e5d9a3bd36305901fc Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 02:59:59 -0700 Subject: [PATCH 215/317] feat(web): boring sidebar hides projects with no active threads (#26387) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.logic.test.ts | 17 +++++++++-------- apps/web/src/components/Sidebar.logic.ts | 7 ++++--- apps/web/src/components/Sidebar.tsx | 8 ++++++-- docs/user/thread-sidebar.md | 3 ++- 4 files changed, 21 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 93f3fb342fb9e..e4fa68a65201b 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -1368,7 +1368,7 @@ describe("sidebarFolderExpansionKeys", () => { }); describe("groupSidebarThreadsByProject", () => { - it("puts each thread in its project's folder and keeps empty projects", () => { + it("puts each thread in its project's folder and drops empty projects unless kept", () => { const remoteEnvironmentId = EnvironmentId.make("environment-remote"); const t3code = ProjectId.make("t3code"); const t3codeRemote = ProjectId.make("t3code-remote"); @@ -1392,21 +1392,22 @@ describe("groupSidebarThreadsByProject", () => { }, ]; - const folders = groupSidebarThreadsByProject(groups, [ + const threads = [ thread("a", localEnvironmentId, t3code), thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), - ]); - - expect( - folders.map((folder) => ({ + ]; + const summarize = (keepEmpty: boolean) => + groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), - })), - ).toEqual([ + })); + + expect(summarize(true)).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); + expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 7f7705573673c..efd8244f8c2b2 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -918,8 +918,8 @@ export function sidebarFolderExpansionKeys(group: { } /** - * Boring sidebar folders. Folders follow `groups` order and include empty - * projects so each one can start a thread. Threads keep their input order. + * Boring sidebar folders. Folders follow `groups` order and threads keep their + * input order. A project with no threads gets a folder only with `keepEmpty`. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -933,6 +933,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], + keepEmpty: boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -944,7 +945,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return folders; + return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 6bda36fc85919..ddc7ad1e7bd29 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -399,6 +399,8 @@ function useSidebarThreads(input: { readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; + // Keeps folders with no active threads, so a project the user filtered to can start one. + readonly keepEmptyFolders: boolean; }) { const { projectScopeKey, @@ -409,6 +411,7 @@ function useSidebarThreads(input: { delegatedUnderParent, routeRowThreadKey, folderGroups, + keepEmptyFolders: keepEmpty, } = input; // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = @@ -495,7 +498,7 @@ function useSidebarThreads(input: { const projectFolders = folderGroups === null ? null - : groupSidebarThreadsByProject(folderGroups, activeThreads).map((folder) => { + : groupSidebarThreadsByProject(folderGroups, activeThreads, keepEmpty).map((folder) => { const collapsed = !resolveProjectExpanded( projectExpandedById, sidebarFolderExpansionKeys(folder.group), @@ -1271,6 +1274,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ? projectGroups : scopedProjectGroups : null, + keepEmptyFolders: scopedProjectGroups.length > 0, }); // Ranked across environments as the palette ranks them, so one @@ -1841,7 +1845,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); - // Boring mode keeps empty project folders, so each can start a thread. + // Boring mode keeps a project the user filtered to, so it can start a thread. if ( !hasProjectFolders && pinnedThreads.length + diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index b5373749a4956..ce16b9df0374b 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -142,7 +142,8 @@ cannot drag or move threads within it. Your saved order returns when you turn it ### Group threads by project (beta) Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active -threads in a folder for each project. Click a folder to open or close it. A closed folder shows how +threads in a folder for each project. A project with no active threads has no folder until you +pick it in the project filter. Click a folder to open or close it. A closed folder shows how many threads it holds, and a dot when one of them needs you. Use the pen button on a folder, or right-click it, to start a thread in that project. Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section From 9f33a1c8361e18f553b387cbb5d3c2c62d56f8ba Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:04:40 -0700 Subject: [PATCH 216/317] feat(web): show T3 Connect tunnel usage and push full accounts to T3 Chat (#26369) Co-authored-by: Claude Opus 5.5 --- apps/web/src/cloud/managedRelayState.ts | 58 ++++++++- .../useCloudLinkController.browser.test.tsx | 42 ++++++- apps/web/src/cloud/useCloudLinkController.ts | 28 ++++- .../clerk/T3ChatUserProfilePage.tsx | 99 ++++----------- .../clerk/T3ConnectAccountPages.tsx | 8 +- .../clerk/T3ConnectUserProfilePage.tsx | 57 ++++++++- .../web/src/components/clerk/useT3ChatLink.ts | 76 +++++++++++ .../cloud/ConnectOnboardingDialog.tsx | 42 +++++++ apps/web/src/components/cloud/TunnelUsage.tsx | 118 ++++++++++++++++++ .../settings/SavedEnvironmentRows.tsx | 60 ++++++++- .../src/components/settings/settingsSearch.ts | 2 +- .../environments/ManagedTunnelLimits.test.ts | 6 +- .../src/environments/ManagedTunnelLimits.ts | 3 +- packages/contracts/src/relay.ts | 2 + 14 files changed, 506 insertions(+), 95 deletions(-) create mode 100644 apps/web/src/components/clerk/useT3ChatLink.ts create mode 100644 apps/web/src/components/cloud/TunnelUsage.tsx diff --git a/apps/web/src/cloud/managedRelayState.ts b/apps/web/src/cloud/managedRelayState.ts index f562373463e1b..c5f7536305337 100644 --- a/apps/web/src/cloud/managedRelayState.ts +++ b/apps/web/src/cloud/managedRelayState.ts @@ -12,10 +12,12 @@ import { createAtomCommandScheduler, createRuntimeCommand, } from "@t3tools/client-runtime/state/runtime"; -import type { - RelayClientDeviceRecord, - RelayClientEnvironmentRecord, - RelayT3ChatLinkResponse, +import { + DEFAULT_MANAGED_TUNNEL_LIMIT, + T3CHAT_PLAN_TUNNEL_LIMITS, + type RelayClientDeviceRecord, + type RelayClientEnvironmentRecord, + type RelayT3ChatLinkResponse, } from "@t3tools/contracts/relay"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -144,3 +146,51 @@ export function useManagedRelayDevices() { refresh, }; } + +/** How this account can raise its tunnel cap: link T3 Chat, or upgrade the linked plan. */ +export type ManagedTunnelUpgrade = "link" | "pro" | "premier" | null; + +// Mirrors the relay's cap without manual overrides, which the relay does not expose. +function managedTunnelCap(t3Chat: RelayT3ChatLinkResponse): { + readonly limit: number; + readonly upgrade: ManagedTunnelUpgrade; +} { + if (!t3Chat.available) return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: null }; + switch (t3Chat.link?.plan) { + case "premier": + return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.premier, upgrade: null }; + case "pro": + return { limit: T3CHAT_PLAN_TUNNEL_LIMITS.pro, upgrade: "premier" }; + case "free": + return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "pro" }; + case undefined: + return { limit: DEFAULT_MANAGED_TUNNEL_LIMIT, upgrade: "link" }; + } +} + +/** Refetches the account's environments and T3 Chat plan after a tunnel changes. */ +export function refreshManagedTunnelUsage() { + const accountId = appAtomRegistry.get(managedRelaySessionAtom)?.accountId; + if (!accountId) return; + managedRelayQueryManager.refreshEnvironments(appAtomRegistry, accountId); + managedRelayQueryManager.refreshT3ChatLink(appAtomRegistry, accountId); +} + +/** Managed tunnels on this account and its cap. Null fields are still loading or failed. */ +export function useManagedTunnelUsage() { + const environments = useManagedRelayEnvironments(); + const t3Chat = useManagedRelayT3ChatLink(); + const used = + environments.accountId === null + ? null + : (environments.data?.filter( + (environment) => environment.endpoint.providerKind === "cloudflare_tunnel", + ).length ?? null); + const cap = t3Chat.accountId !== null && t3Chat.data ? managedTunnelCap(t3Chat.data) : null; + return { + used, + limit: cap?.limit ?? null, + upgrade: cap?.upgrade ?? null, + refresh: refreshManagedTunnelUsage, + }; +} diff --git a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx index 514d0bdd08039..5884ee5c19df4 100644 --- a/apps/web/src/cloud/useCloudLinkController.browser.test.tsx +++ b/apps/web/src/cloud/useCloudLinkController.browser.test.tsx @@ -4,7 +4,12 @@ import { EnvironmentId, type EnvironmentCloudLinkStateResult, } from "@t3tools/contracts"; -import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; +import { + RelayEnvironmentLinkLimitExceededError, + type RelayManagedEndpointProviderKind, +} from "@t3tools/contracts/relay"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { useLayoutEffect } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; @@ -88,6 +93,7 @@ vi.mock(import("./publicConfig"), async (original) => ({ resolveRelayClerkTokenOptions: () => ({}) as never, })); +import { CloudEnvironmentLinkError } from "./linkEnvironment"; import { useCloudLinkController, type CloudLinkDesiredState } from "./useCloudLinkController"; const target = { @@ -125,9 +131,8 @@ async function mountController() { />, ); return Object.assign(screen, { - get controller() { - return controller; - }, + // Object.assign would copy a getter's value once, so read the latest render through a function. + controller: () => controller, async reconcile(desired: CloudLinkDesiredState) { let succeeded = false; await act(async () => { @@ -225,4 +230,33 @@ describe("useCloudLinkController", () => { expect(testState.link).not.toHaveBeenCalled(); expect(testState.preferences).not.toHaveBeenCalled(); }); + + it("reports a relay tunnel cap refusal as tunnelLimit instead of an error", async () => { + using _setup = setup(); + testState.snapshot = { ...linkedState, linked: false, managedTunnelActive: false }; + testState.link.mockResolvedValueOnce( + AsyncResult.failure( + Cause.fail( + new CloudEnvironmentLinkError({ + message: "environment-links failed", + cause: new ManagedRelay.ManagedRelayRequestFailedError({ + action: "link relay environment", + cause: null, + relayError: new RelayEnvironmentLinkLimitExceededError({ + code: "environment_link_limit_exceeded", + maxTunnels: 3, + traceId: "trace-1", + }), + }), + }), + ), + ), + ); + await using screen = await mountController(); + + expect(await screen.reconcile({ managedTunnel: true, publish: false })).toBe(false); + expect(screen.controller()?.tunnelLimit).toBe(3); + expect(screen.controller()?.operationError).toBeNull(); + expect(testState.toast).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/cloud/useCloudLinkController.ts b/apps/web/src/cloud/useCloudLinkController.ts index 69f3ac70a7515..1ae60b8c38382 100644 --- a/apps/web/src/cloud/useCloudLinkController.ts +++ b/apps/web/src/cloud/useCloudLinkController.ts @@ -7,18 +7,22 @@ import { AuthRelayWriteScope, type EnvironmentCloudLinkStateResult, } from "@t3tools/contracts"; +import { ManagedRelay } from "@t3tools/client-runtime/relay"; import type { RelayManagedEndpointProviderKind } from "@t3tools/contracts/relay"; import { isAtomCommandInterrupted, settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import * as Schema from "effect/Schema"; import { useState } from "react"; import { toastManager } from "../components/ui/toast"; import { relayEnvironmentDiscovery } from "../state/relay"; import { readEnvironmentScope } from "../state/session"; import { useAtomCommand } from "../state/use-atom-command"; +import { CloudEnvironmentLinkError } from "./linkEnvironment"; +import { refreshManagedTunnelUsage } from "./managedRelayState"; import { linkPrimaryEnvironment as linkPrimaryEnvironmentAtom, unlinkPrimaryEnvironment as unlinkPrimaryEnvironmentAtom, @@ -46,6 +50,18 @@ function isManagedTunnelActive( ); } +const isManagedRelayRequestFailedError = Schema.is(ManagedRelay.ManagedRelayRequestFailedError); + +/** The account's tunnel cap when the relay refused a new tunnel for being at it. */ +function tunnelLimitFromLinkFailure(cause: unknown): number | null { + if (!(cause instanceof CloudEnvironmentLinkError)) return null; + const relayCause = cause.cause; + if (!isManagedRelayRequestFailedError(relayCause)) return null; + return relayCause.relayError?._tag === "RelayEnvironmentLinkLimitExceededError" + ? relayCause.relayError.maxTunnels + : null; +} + export function useCloudLinkController() { const { getToken, isSignedIn } = useAuth(); const refreshRelayEnvironments = useAtomCommand(relayEnvironmentDiscovery.refresh, { @@ -68,6 +84,7 @@ export function useCloudLinkController() { ? relayEnvironments.get(primaryEnvironmentId)?.environment.endpoint.providerKind : undefined; const [operationError, setOperationError] = useState(null); + const [tunnelLimit, setTunnelLimit] = useState(null); const reportUpdateFailure = (cause: unknown) => { const message = cause instanceof Error ? cause.message : "Could not update T3 Connect access."; @@ -100,6 +117,7 @@ export function useCloudLinkController() { const reconcileCloudState = async (desired: CloudLinkDesiredState): Promise => { setOperationError(null); + setTunnelLimit(null); const target = primaryCloudLinkState.target; if (!target) { reportUpdateFailure(new Error("Local environment is not ready yet.")); @@ -138,6 +156,7 @@ export function useCloudLinkController() { target, clerkToken: tokenResult._tag === "Success" ? (tokenResult.value ?? null) : null, }); + refreshManagedTunnelUsage(); if (unlinkResult._tag === "Failure") { if (!isAtomCommandInterrupted(unlinkResult)) { reportUpdateFailure(squashAtomCommandFailure(unlinkResult)); @@ -168,9 +187,15 @@ export function useCloudLinkController() { clerkToken, mode: desired.managedTunnel ? "managed" : "publish_only", }); + // Any link attempt can change the account's tunnel count. + refreshManagedTunnelUsage(); if (linkResult._tag === "Failure") { if (!isAtomCommandInterrupted(linkResult)) { - reportUpdateFailure(squashAtomCommandFailure(linkResult)); + const cause = squashAtomCommandFailure(linkResult); + // The caller shows an upgrade prompt instead of an error. + const limit = tunnelLimitFromLinkFailure(cause); + if (limit === null) reportUpdateFailure(cause); + else setTunnelLimit(limit); } primaryCloudLinkState.refresh(); return false; @@ -215,6 +240,7 @@ export function useCloudLinkController() { publishAgentActivity, holdWebhooksWhileOffline, operationError, + tunnelLimit, reconcileCloudState, }; } diff --git a/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx b/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx index 689e04661127c..0a1bbe57abbbd 100644 --- a/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ChatUserProfilePage.tsx @@ -1,95 +1,43 @@ -import { findErrorTraceId } from "@t3tools/client-runtime/errors"; -import { - isAtomCommandInterrupted, - squashAtomCommandFailure, -} from "@t3tools/client-runtime/state/runtime"; import { T3CHAT_PLAN_TUNNEL_LIMITS, type RelayT3ChatLink } from "@t3tools/contracts/relay"; import { MessageSquareIcon } from "lucide-react"; -import { useState } from "react"; +import type { ReactNode } from "react"; -import { - createManagedRelayT3ChatLinkRequestCommand, - unlinkManagedRelayT3ChatCommand, - useManagedRelayT3ChatLink, -} from "../../cloud/managedRelayState"; -import { readLocalApi } from "../../localApi"; -import { useAtomCommand } from "../../state/use-atom-command"; +import { useManagedTunnelUsage } from "../../cloud/managedRelayState"; +import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter } from "../cloud/TunnelUsage"; import { Button } from "../ui/button"; -import { toastManager } from "../ui/toast"; import { ClerkUserProfilePage, ClerkUserProfileRefreshButton, ClerkUserProfileRow, } from "./ClerkUserProfilePage"; +import { useT3ChatLink } from "./useT3ChatLink"; -function planLabel(link: RelayT3ChatLink): string { +const upgradeLink = ( + + Upgrade on T3 Chat + +); + +function planLabel(link: RelayT3ChatLink): ReactNode { switch (link.plan) { case "premier": return `Premier · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} tunnels`; case "pro": - return `Pro · up to ${T3CHAT_PLAN_TUNNEL_LIMITS.pro} tunnels, or ${T3CHAT_PLAN_TUNNEL_LIMITS.premier} with Premier`; + return ( + <> + Pro · up to {T3CHAT_PLAN_TUNNEL_LIMITS.pro} tunnels. {upgradeLink} to Premier for{" "} + {T3CHAT_PLAN_TUNNEL_LIMITS.premier}. + + ); case "free": - return "Upgrade on T3 Chat for more tunnels"; + return <>{upgradeLink} for more tunnels; } } -function reportFailure(title: string, cause: unknown) { - const traceId = findErrorTraceId(cause); - toastManager.add({ - type: "error", - title, - description: cause instanceof Error ? cause.message : "Try again.", - data: traceId - ? { - secondaryActionProps: { - children: "Copy trace ID", - onClick: () => void navigator.clipboard?.writeText(traceId), - }, - } - : undefined, - }); -} - /** Links a T3 Chat account so paid subscribers get more tunnels. */ export function T3ChatUserProfilePage() { - const linkState = useManagedRelayT3ChatLink(); - const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, { - reportFailure: false, - }); - const unlink = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false }); - const [pending, setPending] = useState<"link" | "unlink" | null>(null); - // Kept so a blocked popup still leaves the user a link to open. - const [linkUrl, setLinkUrl] = useState(null); - - const handleLink = async () => { - const accountId = linkState.accountId; - if (!accountId || pending) return; - setPending("link"); - const result = await createLinkRequest({ accountId }); - if (result._tag === "Success") { - setLinkUrl(result.value); - await readLocalApi() - ?.shell.openExternal(result.value) - .catch(() => undefined); - } else if (!isAtomCommandInterrupted(result)) { - reportFailure("Could not start linking", squashAtomCommandFailure(result)); - } - setPending(null); - }; - - const handleUnlink = async () => { - const accountId = linkState.accountId; - if (!accountId || pending) return; - setPending("unlink"); - const result = await unlink({ accountId }); - if (result._tag === "Success") { - setLinkUrl(null); - linkState.refresh(); - } else if (!isAtomCommandInterrupted(result)) { - reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result)); - } - setPending(null); - }; + const { linkState, pending, linkUrl, link: handleLink, unlink: handleUnlink } = useT3ChatLink(); + const usage = useManagedTunnelUsage(); const data = linkState.data; const link = data?.link ?? null; @@ -102,7 +50,7 @@ export function T3ChatUserProfilePage() { } > @@ -160,6 +108,11 @@ export function T3ChatUserProfilePage() { )} + {usage.used !== null && usage.limit !== null ? ( +
  • + +
  • + ) : null} )} diff --git a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx index 85dcc7dd255c4..3e804bdf68d61 100644 --- a/apps/web/src/components/clerk/T3ConnectAccountPages.tsx +++ b/apps/web/src/components/clerk/T3ConnectAccountPages.tsx @@ -28,22 +28,24 @@ export const T3_CONNECT_ACCOUNT_PAGES = [ }, ] as const; +export type T3ConnectAccountPageUrl = (typeof T3_CONNECT_ACCOUNT_PAGES)[number]["url"]; + type PortalTargets = Readonly>; /** Keep portals rendered while the modal can open; Clerk owns the custom-page DOM nodes. */ export function useT3ConnectAccountPage(): { - readonly open: (() => void) | null; + readonly open: ((page?: T3ConnectAccountPageUrl) => void) | null; readonly portals: ReactNode; } { const clerk = useClerk(); const { isSignedIn } = useAuth(); const [targets, setTargets] = useState({}); - const open = () => { + const open = (page: T3ConnectAccountPageUrl = "t3-connect") => { const setTarget = (key: string, element: HTMLDivElement | undefined) => setTargets((current) => ({ ...current, [key]: element })); clerk.openUserProfile({ - __experimental_startPath: "/t3-connect", + __experimental_startPath: `/${page}`, customPages: T3_CONNECT_ACCOUNT_PAGES.map((page) => ({ label: page.label, url: page.url, diff --git a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx index d0342005e6fd2..be59127723c2d 100644 --- a/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx +++ b/apps/web/src/components/clerk/T3ConnectUserProfilePage.tsx @@ -11,9 +11,11 @@ import { useRef, useState } from "react"; import { deregisterManagedRelayEnvironmentCommand, useManagedRelayEnvironments, + useManagedTunnelUsage, } from "../../cloud/managedRelayState"; import { useAtomCommand } from "../../state/use-atom-command"; -import { Button } from "../ui/button"; +import { T3CHAT_SUBSCRIPTION_URL, TunnelUsageMeter, tunnelUpgradeCopy } from "../cloud/TunnelUsage"; +import { Button, InlineButton } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { toastManager } from "../ui/toast"; @@ -22,6 +24,7 @@ import { ClerkUserProfileRefreshButton, ClerkUserProfileRow, } from "./ClerkUserProfilePage"; +import { useT3ChatLink } from "./useT3ChatLink"; const linkedAtFormatter = new Intl.DateTimeFormat(undefined, { dateStyle: "medium" }); @@ -110,6 +113,53 @@ function T3ConnectEnvironmentRow(props: { ); } +function T3ConnectTunnelUsage() { + const usage = useManagedTunnelUsage(); + const t3Chat = useT3ChatLink(); + if (usage.used === null || usage.limit === null) return null; + return ( +
    + + {usage.upgrade ? ( +

    + {usage.upgrade === "link" && t3Chat.linkUrl ? ( + <> + Confirm on{" "} + + t3.chat + + , then select Refresh. + + ) : ( + <> + {tunnelUpgradeCopy(usage.upgrade)}{" "} + {usage.upgrade === "link" ? ( + void t3Chat.link()}> + {t3Chat.pending === "link" ? "Opening…" : "Link T3 Chat"} + + ) : ( + + Upgrade on T3 Chat + + )} + + )} +

    + ) : null} +
    + ); +} + export function T3ConnectUserProfilePage() { const environmentsState = useManagedRelayEnvironments(); const deregisterEnvironment = useAtomCommand(deregisterManagedRelayEnvironmentCommand, { @@ -188,6 +238,8 @@ export function T3ConnectUserProfilePage() { (environment) => removedEnvironmentLinkedAt.get(environment.environmentId) !== environment.linkedAt, ); + const usage = useManagedTunnelUsage(); + const refreshAll = usage.refresh; const isInitialLoad = !environmentsState.accountId || (environmentsState.data === null && !environmentsState.error); @@ -199,11 +251,12 @@ export function T3ConnectUserProfilePage() { } >
    + {environmentsState.error ? (

    diff --git a/apps/web/src/components/clerk/useT3ChatLink.ts b/apps/web/src/components/clerk/useT3ChatLink.ts new file mode 100644 index 0000000000000..66d2267c62682 --- /dev/null +++ b/apps/web/src/components/clerk/useT3ChatLink.ts @@ -0,0 +1,76 @@ +import { findErrorTraceId } from "@t3tools/client-runtime/errors"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { useState } from "react"; + +import { + createManagedRelayT3ChatLinkRequestCommand, + unlinkManagedRelayT3ChatCommand, + useManagedRelayT3ChatLink, +} from "../../cloud/managedRelayState"; +import { readLocalApi } from "../../localApi"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { toastManager } from "../ui/toast"; + +function reportFailure(title: string, cause: unknown) { + const traceId = findErrorTraceId(cause); + toastManager.add({ + type: "error", + title, + description: cause instanceof Error ? cause.message : "Try again.", + data: traceId + ? { + secondaryActionProps: { + children: "Copy trace ID", + onClick: () => void navigator.clipboard?.writeText(traceId), + }, + } + : undefined, + }); +} + +/** Starts and removes the T3 Chat link that raises the T3 Connect tunnel cap. */ +export function useT3ChatLink() { + const linkState = useManagedRelayT3ChatLink(); + const createLinkRequest = useAtomCommand(createManagedRelayT3ChatLinkRequestCommand, { + reportFailure: false, + }); + const unlinkCommand = useAtomCommand(unlinkManagedRelayT3ChatCommand, { reportFailure: false }); + const [pending, setPending] = useState<"link" | "unlink" | null>(null); + // Kept so a blocked popup still leaves the user a link to open. + const [linkUrl, setLinkUrl] = useState(null); + + const link = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("link"); + const result = await createLinkRequest({ accountId }); + if (result._tag === "Success") { + setLinkUrl(result.value); + await readLocalApi() + ?.shell.openExternal(result.value) + .catch(() => undefined); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not start linking", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + const unlink = async () => { + const accountId = linkState.accountId; + if (!accountId || pending) return; + setPending("unlink"); + const result = await unlinkCommand({ accountId }); + if (result._tag === "Success") { + setLinkUrl(null); + linkState.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + reportFailure("Could not unlink T3 Chat", squashAtomCommandFailure(result)); + } + setPending(null); + }; + + return { linkState, pending, linkUrl, link, unlink }; +} diff --git a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx index 8daf67714cc45..2e6bb927d69dc 100644 --- a/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx +++ b/apps/web/src/components/cloud/ConnectOnboardingDialog.tsx @@ -9,6 +9,7 @@ import { ConnectOnboardingOptOutSchema, EMPTY_CONNECT_ONBOARDING_OPT_OUT_STATE, } from "~/cloud/connectOnboarding"; +import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -19,7 +20,12 @@ import { usePrimaryEnvironmentId, } from "~/state/environments"; import { environmentSession, readEnvironmentScope, useEnvironmentScope } from "~/state/session"; +import { + type T3ConnectAccountPageUrl, + useT3ConnectAccountPage, +} from "../clerk/T3ConnectAccountPages"; import { CloudEnvironmentConnectRows } from "./CloudEnvironmentConnectList"; +import { TunnelLimitAlert } from "./TunnelUsage"; import { Button } from "../ui/button"; import { Checkbox } from "../ui/checkbox"; import { Dialog } from "../ui/dialog"; @@ -63,6 +69,7 @@ function ConfiguredConnectOnboardingDialog() { primaryEnvironmentId === null ? environmentsReady : primarySessionState._tag !== "Initial"; const controller = useCloudLinkController(); + const accountPage = useT3ConnectAccountPage(); const showPublishStep = canManageRelay && controller.linkState.target !== null; const steps: ReadonlyArray = showPublishStep ? ["publish", "devices"] @@ -180,6 +187,15 @@ function ConfiguredConnectOnboardingDialog() { setStep("devices"); }; + const openAccountPage = accountPage.open; + // The account page is a Clerk modal, so close this dialog before it opens. + const leaveForAccountPage = openAccountPage + ? (page: T3ConnectAccountPageUrl) => { + complete(); + openAccountPage(page); + } + : null; + return (

    { if ( primaryEnvironmentId !== null && @@ -274,6 +292,8 @@ function ConfiguredConnectOnboardingDialog() { )} + {/* Base UI renders Root children while closed, so this keeps the account page mounted. */} + {accountPage.portals} ); } @@ -288,6 +308,8 @@ function PublishStep({ publishAgentActivity, disabled, operationError, + tunnelLimit, + onOpenAccountPage, onExposeEnvironmentChange, onPublishAgentActivityChange, }: { @@ -295,6 +317,8 @@ function PublishStep({ readonly publishAgentActivity: boolean; readonly disabled: boolean; readonly operationError: string | null; + readonly tunnelLimit: number | null; + readonly onOpenAccountPage: ((page: T3ConnectAccountPageUrl) => void) | null; readonly onExposeEnvironmentChange: (enabled: boolean) => void; readonly onPublishAgentActivityChange: (enabled: boolean) => void; }) { @@ -317,10 +341,28 @@ function PublishStep({ />
    {operationError ?

    {operationError}

    : null} + {tunnelLimit !== null ? ( + + ) : null}
    ); } +function OnboardingTunnelLimitAlert({ + onOpenAccountPage, +}: { + readonly onOpenAccountPage: ((page: T3ConnectAccountPageUrl) => void) | null; +}) { + const { upgrade } = useManagedTunnelUsage(); + return ( + onOpenAccountPage("t3-chat") : null} + onManageTunnels={onOpenAccountPage ? () => onOpenAccountPage("t3-connect") : null} + /> + ); +} + function OnboardingToggleRow({ title, description, diff --git a/apps/web/src/components/cloud/TunnelUsage.tsx b/apps/web/src/components/cloud/TunnelUsage.tsx new file mode 100644 index 0000000000000..0b392f77b5416 --- /dev/null +++ b/apps/web/src/components/cloud/TunnelUsage.tsx @@ -0,0 +1,118 @@ +import { T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; +import { TriangleAlertIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ManagedTunnelUpgrade } from "../../cloud/managedRelayState"; +import { cn } from "../../lib/utils"; +import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; +import { Button } from "../ui/button"; + +// Above this, one slot per tunnel is too thin to read, so the bar fills instead. +const MAX_SEGMENTS = 10; + +export const T3CHAT_SUBSCRIPTION_URL = "https://t3.chat/settings/subscription"; + +export function tunnelUpgradeCopy(upgrade: Exclude): string { + const { pro, premier } = T3CHAT_PLAN_TUNNEL_LIMITS; + switch (upgrade) { + case "link": + return `Link T3 Chat Pro for up to ${pro} tunnels, or Premier for up to ${premier}.`; + case "pro": + return `Upgrade to T3 Chat Pro for up to ${pro} tunnels, or Premier for up to ${premier}.`; + case "premier": + return `Upgrade to T3 Chat Premier for up to ${premier} tunnels.`; + } +} + +/** "2 of 3 tunnels in use" with one bar slot per tunnel. */ +export function TunnelUsageMeter({ + used, + limit, + action, + className, +}: { + readonly used: number; + readonly limit: number; + readonly action?: ReactNode; + readonly className?: string; +}) { + const full = used >= limit; + const fill = full ? "bg-warning" : "bg-foreground/72"; + return ( +
    +
    + + {used} of {limit} tunnels in use + + {action} +
    +
    + {limit <= MAX_SEGMENTS ? ( + Array.from({ length: limit }, (_, index) => ( + + )) + ) : ( + + + + )} +
    +
    + ); +} + +/** Shown when the relay refuses a new tunnel because the account is at its cap. */ +export function TunnelLimitAlert({ + upgrade, + onLinkT3Chat, + onManageTunnels, +}: { + readonly upgrade: ManagedTunnelUpgrade; + readonly onLinkT3Chat: (() => void) | null; + readonly onManageTunnels: (() => void) | null; +}) { + const action = + upgrade === "link" && onLinkT3Chat ? ( + + ) : upgrade === "pro" || upgrade === "premier" ? ( + + ) : onManageTunnels ? ( + + ) : null; + return ( + + + You're out of tunnels (they cost us money). + + {upgrade === "link" + ? "Switch to Tailscale or get more tunnels by linking your T3 Chat sub (sorry!)" + : upgrade + ? "Switch to Tailscale or get more tunnels by upgrading your T3 Chat sub (sorry!)" + : "Switch to Tailscale or deregister an environment you no longer use (sorry!)"} + + {action ? {action} : null} + + ); +} diff --git a/apps/web/src/components/settings/SavedEnvironmentRows.tsx b/apps/web/src/components/settings/SavedEnvironmentRows.tsx index 1d36ef00e47a9..fee5b9857a6ae 100644 --- a/apps/web/src/components/settings/SavedEnvironmentRows.tsx +++ b/apps/web/src/components/settings/SavedEnvironmentRows.tsx @@ -39,7 +39,7 @@ import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "../ui/men import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { Button } from "../ui/button"; +import { Button, InlineButton } from "../ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "../ui/empty"; import { resolveServerConfigVersionMismatch, @@ -49,6 +49,9 @@ import { import { hasCloudPublicConfig } from "~/cloud/publicConfig"; import { isElectron } from "~/env"; import { useCloudLinkController } from "~/cloud/useCloudLinkController"; +import { useManagedTunnelUsage } from "~/cloud/managedRelayState"; +import { useT3ConnectAccountPage } from "../clerk/T3ConnectAccountPages"; +import { TunnelLimitAlert, TunnelUsageMeter } from "../cloud/TunnelUsage"; import { environmentCatalog } from "~/connection/catalog"; import { readEnvironmentScope } from "~/state/session"; import { @@ -463,6 +466,56 @@ function CloudLinkSwitch({ ); } +/** Tunnel meter, plus the upgrade prompt after the relay refuses a tunnel at the cap. */ +function T3ConnectTunnelUsage({ tunnelLimit }: { readonly tunnelLimit: number | null }) { + const usage = useManagedTunnelUsage(); + const accountPage = useT3ConnectAccountPage(); + const openAccountPage = accountPage.open; + // Usage when the relay refused. The relay also counts tunnels still being set up, so only + // a freed tunnel or a higher cap clears the alert, not a meter that merely shows room. + const [usedAtRefusal, setUsedAtRefusal] = useState(null); + if (tunnelLimit === null) { + if (usedAtRefusal !== null) setUsedAtRefusal(null); + } else if (usedAtRefusal === null && usage.used !== null) { + setUsedAtRefusal(usage.used); + } + const showLimitAlert = + tunnelLimit !== null && + !(usedAtRefusal !== null && usage.used !== null && usage.used < usedAtRefusal) && + !(usage.limit !== null && usage.limit > tunnelLimit); + const meter = + usage.used !== null && usage.limit !== null ? ( + openAccountPage("t3-chat")}>Get more tunnels + ) : null + } + /> + ) : null; + return ( + <> + {meter || showLimitAlert ? ( +
    + {meter} + {showLimitAlert ? ( + openAccountPage("t3-chat") : null} + onManageTunnels={openAccountPage ? () => openAccountPage("t3-connect") : null} + /> + ) : null} +
    + ) : null} + {/* Kept outside the conditional so an open account page does not remount. */} + {accountPage.portals} + + ); +} + function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: boolean }) { const primaryEnvironmentId = usePrimaryEnvironmentId(); const { @@ -474,6 +527,7 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b publishAgentActivity, holdWebhooksWhileOffline, operationError, + tunnelLimit, reconcileCloudState, } = useCloudLinkController(); const { openAuthPrompt } = useT3ConnectAuthPrompt(); @@ -625,7 +679,9 @@ function ConfiguredCloudLinkRow({ canManageRelay }: { readonly canManageRelay: b /> ) } - /> + > + {signedOut ? null : } +
    ) : linkRevoked ? ( { it.effect("rejects concurrent reservations past the limit", () => { const reserved: Array = []; - const attempts = ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT + 2; + const attempts = DEFAULT_MANAGED_TUNNEL_LIMIT + 2; return Effect.gen(function* () { const limits = yield* ManagedTunnelLimits.ManagedTunnelLimits; @@ -140,7 +140,7 @@ describe("ManagedTunnelLimits", () => { { concurrency: "unbounded" }, ); - expect(reserved).toHaveLength(ManagedTunnelLimits.DEFAULT_MANAGED_TUNNEL_LIMIT); + expect(reserved).toHaveLength(DEFAULT_MANAGED_TUNNEL_LIMIT); expect( results.filter( (result) => diff --git a/infra/relay/src/environments/ManagedTunnelLimits.ts b/infra/relay/src/environments/ManagedTunnelLimits.ts index 93aea073c71d3..aa373ad27ffb2 100644 --- a/infra/relay/src/environments/ManagedTunnelLimits.ts +++ b/infra/relay/src/environments/ManagedTunnelLimits.ts @@ -6,7 +6,7 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schema from "effect/Schema"; import * as SqlError from "effect/sql/SqlError"; -import { T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; +import { DEFAULT_MANAGED_TUNNEL_LIMIT, T3CHAT_PLAN_TUNNEL_LIMITS } from "@t3tools/contracts/relay"; import * as RelayDb from "../db.ts"; import { @@ -16,7 +16,6 @@ import { } from "../persistence/schema.ts"; import * as T3ChatAccounts from "../t3chat/T3ChatAccounts.ts"; -export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; // Reserve-to-tunnel takes two Cloudflare calls capped at 8 seconds each; a reservation older than this was abandoned. const MANAGED_TUNNEL_PENDING_WINDOW_MINUTES = 15; diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 983f74de418fa..4dbb6a199f472 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -966,6 +966,8 @@ export type RelayOkResponse = typeof RelayOkResponse.Type; export const RelayT3ChatPlan = Schema.Literals(["free", "pro", "premier"]); export type RelayT3ChatPlan = typeof RelayT3ChatPlan.Type; +export const DEFAULT_MANAGED_TUNNEL_LIMIT = 3; + // A linked paid T3 Chat plan raises the default managed tunnel cap. export const T3CHAT_PLAN_TUNNEL_LIMITS = { pro: 10, premier: 100 } as const; From 40d335a7ba8ed0e6158a400188b5ed174e6ed19b Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:12:06 -0700 Subject: [PATCH 217/317] fix(web): settling a thread no longer scrolls the sidebar to the bottom (#26392) Co-authored-by: Claude Opus 5.5 --- apps/web/src/lib/sidebarTree.browser.test.ts | 19 +++++++++++++++++++ apps/web/src/lib/sidebarTree.ts | 3 ++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/sidebarTree.browser.test.ts b/apps/web/src/lib/sidebarTree.browser.test.ts index 3d57aacb86438..6b02cd3b842bf 100644 --- a/apps/web/src/lib/sidebarTree.browser.test.ts +++ b/apps/web/src/lib/sidebarTree.browser.test.ts @@ -172,6 +172,25 @@ describe("sidebar keyboard navigation", () => { expect([firstAction.tabIndex, secondAction.tabIndex]).toEqual([-1, 0]); }); + it("keeps the scroll position when focus follows a row that moved away", async () => { + using tree = setup(); + Object.assign(tree.root.style, { height: "100px", overflow: "auto" }); + const settled = tree.row("settled"); + const settle = document.createElement("button"); + settle.textContent = "Settle"; + settled.after(settle); + settle.dataset.sidebarActionFor = "settled"; + for (let index = 0; index < 20; index++) tree.row(`row-${index}`).style.display = "block"; + tree.install(); + settle.focus(); + // Settling moves the row to the shelf at the bottom and removes the focused button. + settle.remove(); + settled.remove(); + const moved = tree.row("settled"); + await vi.waitFor(() => expect(document.activeElement).toBe(moved)); + expect(tree.root.scrollTop).toBe(0); + }); + it("does not pull focus back after a click outside the tree", async () => { using tree = setup(); const first = tree.row("first"); diff --git a/apps/web/src/lib/sidebarTree.ts b/apps/web/src/lib/sidebarTree.ts index 13dd10340e0b9..07859c72bd901 100644 --- a/apps/web/src/lib/sidebarTree.ts +++ b/apps/web/src/lib/sidebarTree.ts @@ -120,7 +120,8 @@ export function installSidebarTree(root: HTMLElement) { !rows.includes(document.activeElement as HTMLElement) && document.activeElement?.matches(ROW))) ) { - focusRow(selected); + // A settled row moves to the bottom shelf; scrolling after it fights the route reveal. + selected.focus({ preventScroll: true }); } if (ownsFocus && !selected && document.activeElement === document.body) root.focus(); }; From 80fcb7f58f90b43ba5918383cbe38d3ae804da8e Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:24:01 -0700 Subject: [PATCH 218/317] fix(web): boring sidebar folders put your latest message on top (#26391) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ddc7ad1e7bd29..0868554b50ffd 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -337,6 +337,8 @@ interface SidebarThreadPartitionInput { readonly serverConfigs: ReadonlyMap; readonly optimisticDrop: SidebarOptimisticDrop | null; readonly workingShelfEnabled: boolean; + /** Boring mode: folders list active threads by the user's latest message. */ + readonly sortActiveBySend: boolean; // Snooze wakes, the queued-turn grace and the Working shelf's exit grace depend // on the clock, so the minute tick, the precise snooze-wake tick and the inbox // tracker's expiry version are inputs: a new key repartitions. @@ -458,6 +460,7 @@ function useSidebarThreads(input: { serverConfigs, optimisticDrop, workingShelfEnabled, + sortActiveBySend: folderGroups !== null, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, }); @@ -592,6 +595,7 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { serverConfigs, optimisticDrop, workingShelfEnabled, + sortActiveBySend, } = input; const preciseNow = new Date().toISOString(); const visible = filterSidebarV2VisibleThreads(threads, scopedProjectKeys, delegatedUnderParent); @@ -650,9 +654,11 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { } // A loaded config gates dragging only; the sort must not depend on it, so web and mobile agree. const sortedPinned = sortPinnedThreadsForSidebar(pinned); - const sortedActive = workingShelfEnabled - ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) - : sortThreadsForSidebar(active); + const sortedActive = sortActiveBySend + ? sortWorkingThreadsBySend(active) + : workingShelfEnabled + ? sortInboxThreadsByReturn(active, inboxObservation.returnedAt) + : sortThreadsForSidebar(active); return { pinnedThreads: optimisticDrop?.section !== "pinned" || optimisticDrop.order === null From 2a738273e74e2bc4637bb0977fa7818970760621 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 03:25:14 -0700 Subject: [PATCH 219/317] fix(server): threads settle right away when a watched PR merges (#26395) Co-authored-by: Claude Opus 5.5 --- ...chestrationV2PendingBackgroundWork.test.ts | 20 +++++++++++++++++-- .../orchestrationV2PendingBackgroundWork.ts | 9 +++++++-- 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts index 91c827ed81596..1b5331c0d1bed 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.test.ts @@ -374,19 +374,26 @@ describe("derivePendingBackgroundWork kinds", () => { }; const link = ( number: number, - extra: { source?: "agent" | "stack-dismissed"; watched?: boolean }, + extra: { + source?: "agent" | "stack-dismissed"; + watched?: boolean; + state?: "open" | "closed" | "merged"; + }, ) => ({ host: "github.com", repository: "acme/app", number, url: `https://github.com/acme/app/pull/${number}`, source: extra.source ?? "agent", + snapshot: extra.state === undefined ? null : { state: extra.state }, ...(extra.watched === false ? {} : { watch }), }); const pullRequests = [ link(1, {}), link(2, { watched: false }), link(3, { source: "stack-dismissed" }), + link(4, { state: "merged" }), + link(5, { state: "closed" }), ]; it("keeps a settled run waiting on each visible watch, as an awaited monitor", () => { @@ -403,6 +410,12 @@ describe("derivePendingBackgroundWork kinds", () => { kind: "monitor", awaited: true, }, + { + taskId: "pull-request-watch:github.com/acme/app#5", + description: "Watching pull request #5", + kind: "monitor", + awaited: true, + }, ]); expect(backgroundWorkHoldsCompletion(tasks)).toBe(true); }); @@ -415,7 +428,10 @@ describe("derivePendingBackgroundWork kinds", () => { turnItems: [], pullRequests, }).map((task) => task.taskId), - ).toEqual(["pull-request-watch:github.com/acme/app#1"]); + ).toEqual([ + "pull-request-watch:github.com/acme/app#1", + "pull-request-watch:github.com/acme/app#5", + ]); }); it("lists no watch while a run is active", () => { diff --git a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts index 6768d9359ffc7..6dc635b5153a6 100644 --- a/packages/shared/src/orchestrationV2PendingBackgroundWork.ts +++ b/packages/shared/src/orchestrationV2PendingBackgroundWork.ts @@ -112,7 +112,9 @@ type PendingBackgroundWorkRun = Pick; +> & { + readonly snapshot: Pick, "state"> | null; +}; type PendingBackgroundWorkProviderThread = Pick< OrchestrationV2ProviderThread, @@ -291,7 +293,10 @@ function pullRequestWatchTasks( pullRequests: ReadonlyArray | undefined, ): Array { return (pullRequests ?? []).flatMap((link) => - link.watch === undefined || link.source === "stack-dismissed" + // The watch reactor ends a merged PR's watch without waking the agent, so it holds nothing. + link.watch === undefined || + link.source === "stack-dismissed" || + link.snapshot?.state === "merged" ? [] : [ { From d2429f733c30772ab5ef8818685615e656ec432f Mon Sep 17 00:00:00 2001 From: Yash Singh Date: Wed, 28 Oct 2026 05:53:28 -0500 Subject: [PATCH 220/317] fix(server): load long thread history in bounded pages (#26240) --- .../src/features/threads/ThreadFeed.tsx | 119 +++++-- apps/mobile/src/lib/threadActivity.test.ts | 15 + apps/mobile/src/lib/threadActivity.ts | 35 ++- .../src/orchestration-v2/Orchestrator.ts | 5 +- .../orchestration-v2/ProjectionStore.test.ts | 201 ++++++++++++ .../src/orchestration-v2/ProjectionStore.ts | 278 +++++++++++++---- .../ThreadManagementService.ts | 10 +- apps/server/src/orchestration-v2/http.ts | 16 +- .../threadHistoryPaging.test.ts | 106 +++++++ .../orchestration-v2/threadHistoryPaging.ts | 150 +++++++-- apps/server/src/ws.ts | 3 + .../chat/MessagesTimeline.logic.test.ts | 83 +++++ .../components/chat/MessagesTimeline.logic.ts | 49 ++- .../src/components/chat/MessagesTimeline.tsx | 144 +++++++-- .../pullRequest/PullRequestDetailPanel.tsx | 2 +- apps/web/src/session-logic.test.ts | 14 + apps/web/src/session-logic.ts | 2 +- docs/internals/performance-regressions.md | 9 +- .../src/state/boundedThreadSnapshotHttp.ts | 7 +- .../state/orchestrationV2Projection.test.ts | 8 +- .../src/state/orchestrationV2Projection.ts | 5 +- .../src/state/threadCommands.ts | 2 + .../src/state/threadHistoryController.ts | 10 +- .../src/state/threadHistoryHttp.ts | 3 +- .../src/state/threadHistoryMerge.test.ts | 23 ++ .../src/state/threadHistoryMerge.ts | 55 +++- .../src/state/threads-sync.test.ts | 291 +++++++++++++++++- packages/client-runtime/src/state/threads.ts | 203 +++++++----- packages/contracts/src/environmentHttp.ts | 4 +- packages/contracts/src/orchestrationV2.ts | 9 + 30 files changed, 1607 insertions(+), 254 deletions(-) diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f845d923f9a0f..da127a29ef9bf 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1208,6 +1208,92 @@ function compactionLabel(label: string, title: string | undefined) { : label; } +function RunFoldButton({ + entry, + environmentId, + threadId, + onToggle, + rowSizing, + iconColor, +}: { + entry: Extract; + environmentId: EnvironmentId; + threadId: ThreadId; + onToggle: (runId: RunId) => void; + rowSizing: ReturnType; + iconColor: ColorValue; +}) { + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); + const anchorsKey = entry.deferredWorkItemIds?.join("\n") ?? ""; + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey) return; + void Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ).then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }; + const loadOnExpand = useEffectEvent(load); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. + useEffect(() => { + if (entry.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [entry.expanded, anchorsKey, environmentId, threadId]); + const error = anchorsKey && failed ? "Couldn't load work. Tap to retry." : null; + const loading = entry.expanded && anchorsKey !== "" && !failed; + const toggle = () => { + setFailed(false); + if (error && entry.expanded) load(); + else onToggle(entry.runId); + }; + + return ( + + + + {entry.label} + {error ? ` · ${error}` : ""} + + + + {loading ? ( + + {(["40%", "60%", "33%"] as const).map((width) => ( + + + + + ))} + + ) : null} + + ); +} + function renderFeedEntry( info: { item: ThreadFeedRow; index: number }, props: Pick< @@ -1279,30 +1365,15 @@ function renderFeedEntry( if (entry.type === "run-fold") { return ( - props.onToggleTurnFold(entry.runId)} - hitSlop={4} - className="min-h-11 min-w-0 flex-1 flex-row items-center gap-2" - style={{ - minHeight: Math.max(TURN_FOLD_HEIGHT - 3.5, props.workRowSizing.estimatedRowHeight), - }} - > - - {entry.label} - - - + {entry.checkpointSummary ? ( { "message", "message", ]); + const anchor = { + ...command(), + deferredWork: { cursor: "old-work", itemCount: 100, lastOrdinal: 100 }, + }; + const deferredFeed = buildThreadFeed([projected(userMessage(), 0), projected(anchor, 1)]); + const running = { ...latestRun, status: "running" as const, completedAt: null }; + const deferredRows = deriveThreadFeedPresentation(deferredFeed, running, new Set()); + const disclosure = deferredRows.find((entry) => entry.type === "run-fold"); + expect(disclosure).toMatchObject({ deferredWorkItemIds: [anchor.id], expanded: false }); + if (disclosure?.type !== "run-fold") throw new Error("Expected deferred work disclosure"); + expect( + deriveThreadFeedPresentation(deferredFeed, running, new Set([disclosure.runId])).some( + (entry) => entry.type === "activity-group", + ), + ).toBe(true); expect(expanded[4]).toMatchObject({ message: { id: middle.messageId, text: middle.text } }); const active = deriveThreadFeedPresentation( diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 9cdb3b6f8d815..fc085a7c50a0d 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -231,6 +231,7 @@ type ThreadFeedEntryContent = } | { readonly type: "run-fold"; + readonly deferredWorkItemIds?: ReadonlyArray; readonly id: string; readonly createdAt: string; readonly runId: RunId; @@ -1082,6 +1083,7 @@ function threadFeedActivityIsVisible( } interface ThreadFeedRunFold { + readonly deferredWorkItemIds?: ReadonlyArray; readonly runId: RunId; readonly createdAt: string; readonly hiddenEntryIds: ReadonlySet; @@ -1273,7 +1275,17 @@ function deriveThreadFeedRunFolds( const duration = elapsedMs === null ? null : formatDuration(elapsedMs); const interrupted = latestRunMatches && (latestRun.status === "interrupted" || latestRun.status === "cancelled"); + const deferredWorkItemIds = group.entries.flatMap((entry) => + entry.type === "activity-group" + ? entry.activities.flatMap((activity) => + activity.projectedItem.item.deferredWork + ? [String(activity.projectedItem.sourceItemId)] + : [], + ) + : [], + ); foldsByAnchorId.set(firstHiddenEntry.id, { + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), runId, createdAt: firstHiddenEntry.createdAt, hiddenEntryIds, @@ -1286,6 +1298,23 @@ function deriveThreadFeedRunFolds( : "Worked", }); } + const covered = new Set( + [...foldsByAnchorId.values()].flatMap((fold) => [...fold.hiddenEntryIds]), + ); + for (const entry of feed) { + if (entry.type !== "activity-group" || covered.has(entry.id)) continue; + const deferredWorkItemIds = entry.activities.flatMap((activity) => + activity.projectedItem.item.deferredWork ? [String(activity.projectedItem.sourceItemId)] : [], + ); + if (deferredWorkItemIds.length === 0) continue; + foldsByAnchorId.set(entry.id, { + runId: RunId.make(`deferred-work:${entry.id}`), + createdAt: entry.createdAt, + hiddenEntryIds: new Set([entry.id]), + label: "Earlier work", + deferredWorkItemIds, + }); + } return foldsByAnchorId; } @@ -1501,11 +1530,13 @@ export function deriveThreadFeedPresentation( row.runId !== fold.runId || row.createdAt !== fold.createdAt || row.label !== fold.label || - row.expanded !== expanded + row.expanded !== expanded || + row.deferredWorkItemIds?.join("|") !== fold.deferredWorkItemIds?.join("|") ) { row = { type: "run-fold", id: `run-fold:${fold.runId}`, + ...(fold.deferredWorkItemIds ? { deferredWorkItemIds: fold.deferredWorkItemIds } : {}), createdAt: fold.createdAt, runId: fold.runId, label: fold.label, @@ -2189,7 +2220,7 @@ export function buildThreadFeed( for (let index = restart?.row ?? 0; index < visibleTurnItems.length; index += 1) { const row = visibleTurnItems[index]!; const item = row.item; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "todo_list" || item.type === "checkpoint") continue; if ( item.type === "user_message" && diff --git a/apps/server/src/orchestration-v2/Orchestrator.ts b/apps/server/src/orchestration-v2/Orchestrator.ts index 3f76bd6185eb7..91acb881637c1 100644 --- a/apps/server/src/orchestration-v2/Orchestrator.ts +++ b/apps/server/src/orchestration-v2/Orchestrator.ts @@ -344,6 +344,7 @@ export interface OrchestratorV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + workMode?: "deferred" | "work", ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -13281,9 +13282,9 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio ); }), getThreadHistoryPage: Effect.fn("OrchestratorV2.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return projectionStore - .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly) + .getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly, workMode) .pipe(Effect.mapError((cause) => new OrchestratorProjectionError({ threadId, cause }))); }, ), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.test.ts b/apps/server/src/orchestration-v2/ProjectionStore.test.ts index 5bca01a57fe99..2af87ecf4feb7 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.test.ts @@ -10,6 +10,7 @@ import { type ModelSelection, type OrchestrationV2ProviderThread, type OrchestrationV2TurnItem, + type OrchestrationV2ThreadHistoryPage, type OrchestrationV2ThreadShellSnapshot, NodeId, PlanId, @@ -1972,6 +1973,206 @@ it.layer(layerTest)("ProjectionStoreV2", (it) => { Array.from({ length: 1_000 }, (_, index) => `turn-item:bounded-sql-history:${index + 1}`), ); + yield* setNewestTurnType("command_execution"); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:1', + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') + WHERE thread_id = ${threadId} + `; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 1000, + payload_json = json_set(payload_json, '$.ordinal', 1000) + WHERE thread_id = ${threadId} AND ordinal = 999`; + const promptId = TurnItemId.make("turn-item:bounded-sql-history:1"); + const { statements: deferredStatements, tracer: deferredTracer } = traceSqlStatements(); + const deferred = yield* projectionStore + .getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }) + .pipe(Effect.withTracer(deferredTracer)); + const deferredQuery = deferredStatements.find((statement) => + statement.includes("blocked_runs AS MATERIALIZED"), + ); + assert.isDefined(deferredQuery); + const deferredPlan = yield* sql.unsafe<{ + readonly id: number; + readonly parent: number; + readonly detail: string; + }>(`EXPLAIN QUERY PLAN ${deferredQuery}`); + const blockedRunsPlan = deferredPlan.find((row) => row.detail === "MATERIALIZE blocked_runs"); + assert.isDefined(blockedRunsPlan); + const blockedLookup = deferredPlan.find( + (row) => row.parent === blockedRunsPlan!.id && row.detail.startsWith("SEARCH "), + ); + assert.include(blockedLookup?.detail ?? "", "run_id=?"); + assert.lengthOf(deferred.projection.visibleTurnItems, 3); + assert.strictEqual(deferred.projection.visibleTurnItems[0]?.sourceItemId, promptId); + const anchor = deferred.projection.visibleTurnItems[1]!; + assert.strictEqual(anchor.item.deferredWork?.itemCount, 999); + const workIds = new Set(); + let workCursor: string | null = anchor.item.deferredWork!.cursor; + for (let pages = 0; workCursor !== null; pages += 1) { + assert.isBelow(pages, 20); + const page: OrchestrationV2ThreadHistoryPage = yield* projectionStore.getThreadHistoryPage( + threadId, + workCursor, + anchor.sourceItemId, + false, + "work", + ); + assert.isAtMost(page.items.length, 75); + for (const row of page.items) workIds.add(row.sourceItemId); + workCursor = page.nextCursor; + } + assert.strictEqual(workIds.size, 999); + for (const toolName of ["t3-code.delegate_task", "mcp__t3_code__delegate_task"]) { + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'dynamic_tool', + payload_json = json_set(payload_json, '$.type', 'dynamic_tool', '$.toolName', ${toolName}) + WHERE thread_id = ${threadId} AND ordinal = 2`; + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isUndefined( + window.projection.visibleTurnItems.find((row) => row.item.ordinal === 2)?.item + .deferredWork, + ); + assert.strictEqual( + window.projection.visibleTurnItems.find((row) => row.item.deferredWork)?.item.ordinal, + 3, + ); + const page = yield* projectionStore.getThreadHistoryPage( + threadId, + encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: "turn-item:bounded-sql-history:1000", + position: 1000, + }), + undefined, + false, + "deferred", + ); + assert.isUndefined(page.items.find((row) => row.item.ordinal === 2)?.item.deferredWork); + } + yield* sql`UPDATE orchestration_v2_projection_turn_items SET type = 'command_execution', + payload_json = json_set(payload_json, '$.type', 'command_execution') WHERE thread_id = ${threadId} AND ordinal = 2`; + const sharedRunId = "run:bounded-sql-history:1"; + for (const [ordinal, status] of [ + [0, "completed"], + [1, "superseded"], + [2, "running"], + ] as const) { + const attempt = { + id: `attempt:bounded-sql-history:${ordinal}`, + runId: sharedRunId, + attemptOrdinal: ordinal + 1, + rootNodeId: `node:bounded-sql-history:attempt-${ordinal === 0 ? 1 : ordinal}`, + providerInstanceId, + providerThreadId: "provider-thread:bounded-sql-history", + providerTurnId: null, + reason: ordinal === 1 ? "initial" : "steering_restart", + status, + startedAt: nowIso, + completedAt: status === "superseded" ? nowIso : null, + }; + yield* sql`INSERT INTO orchestration_v2_projection_run_attempts + (attempt_id, thread_id, run_id, attempt_ordinal, root_node_id, provider, provider_instance_id, provider_thread_id, provider_turn_id, status, payload_json) + VALUES (${attempt.id}, ${threadId}, ${sharedRunId}, ${attempt.attemptOrdinal}, ${attempt.rootNodeId}, ${providerInstanceId}, ${providerInstanceId}, ${attempt.providerThreadId}, NULL, ${status}, ${encodeUnknownJsonString(attempt)})`; + } + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET node_id = CASE WHEN ordinal < 990 AND ordinal <> 10 THEN 'node:bounded-sql-history:attempt-1' ELSE 'node:bounded-sql-history:attempt-2' END, + payload_json = json_set(payload_json, '$.nodeId', CASE WHEN ordinal < 990 AND ordinal <> 10 THEN 'node:bounded-sql-history:attempt-1' ELSE 'node:bounded-sql-history:attempt-2' END) + WHERE thread_id = ${threadId}`; + for (const status of ["completed", "running"]) { + yield* sql`UPDATE orchestration_v2_projection_runs SET status = ${status}, + payload_json = json_set(payload_json, '$.status', ${status}) WHERE run_id = ${sharedRunId}`; + const window = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + const oldWork = window.projection.visibleTurnItems.find((row) => row.item.deferredWork); + assert.strictEqual(oldWork?.item.deferredWork?.itemCount, 987); + let cursor: string | null = oldWork!.item.deferredWork!.cursor; + const loaded = new Set(); + for (let pages = 0; cursor !== null; pages += 1) { + assert.isBelow(pages, 20); + const page: OrchestrationV2ThreadHistoryPage = + yield* projectionStore.getThreadHistoryPage( + threadId, + cursor, + oldWork!.sourceItemId, + false, + "work", + ); + for (const row of page.items) loaded.add(row.sourceItemId); + cursor = page.nextCursor; + } + assert.strictEqual(loaded.size, 987); + assert.isFalse(loaded.has(TurnItemId.make("turn-item:bounded-sql-history:10"))); + } + yield* sql`DELETE FROM orchestration_v2_projection_run_attempts WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_runs SET status = 'completed', + payload_json = json_set(payload_json, '$.status', 'completed') WHERE run_id = ${sharedRunId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET node_id = NULL, + payload_json = json_set(payload_json, '$.nodeId', NULL) WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = NULL, + payload_json = json_set(payload_json, '$.runId', NULL) + WHERE thread_id = ${threadId} AND ordinal > 1 AND ordinal < 851`; + const inlineWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isFalse(inlineWindow.projection.visibleTurnItems.some((row) => row.item.deferredWork)); + yield* sql`UPDATE orchestration_v2_projection_turn_items SET run_id = ${sharedRunId}, + payload_json = json_set(payload_json, '$.runId', ${sharedRunId}) + WHERE thread_id = ${threadId} AND ordinal = 850`; + const deferredWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.isTrue( + deferredWindow.projection.visibleTurnItems.some( + (row) => row.item.deferredWork?.itemCount === 151, + ), + ); + yield* sql`UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:1', + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:1') + WHERE thread_id = ${threadId}`; + yield* sql`UPDATE orchestration_v2_projection_turn_items SET ordinal = 999, + payload_json = json_set(payload_json, '$.ordinal', 999) + WHERE turn_item_id = 'turn-item:bounded-sql-history:999'`; + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'command_execution', payload_json = json_set(payload_json, '$.type', 'command_execution') + WHERE thread_id = ${threadId} AND ordinal = 1 + `; + const backgroundWindow = yield* projectionStore.getThreadSnapshotWindow(threadId, { + rowLimit: sqlPageLimit, + userTurnLimit: 10, + deferWork: true, + }); + assert.lengthOf(backgroundWindow.projection.visibleTurnItems, sqlPageLimit); + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET type = 'user_message', payload_json = json_set(payload_json, '$.type', 'user_message') + WHERE thread_id = ${threadId} AND ordinal = 1 + `; + yield* sql` + UPDATE orchestration_v2_projection_turn_items + SET run_id = 'run:bounded-sql-history:' || ordinal, + payload_json = json_set(payload_json, '$.runId', 'run:bounded-sql-history:' || ordinal) + WHERE thread_id = ${threadId} + `; + yield* setNewestTurnType("user_message"); + const tiedIds = [ TurnItemId.make("turn-item:bounded-sql-history:tie:b"), TurnItemId.make("turn-item:bounded-sql-history:tie:a"), diff --git a/apps/server/src/orchestration-v2/ProjectionStore.ts b/apps/server/src/orchestration-v2/ProjectionStore.ts index 1e9176d1bed69..2a21bfae4bd32 100644 --- a/apps/server/src/orchestration-v2/ProjectionStore.ts +++ b/apps/server/src/orchestration-v2/ProjectionStore.ts @@ -97,10 +97,14 @@ import { isConversationHistoryItem, THREAD_HISTORY_MAX_RAW_TURNS, missingInterruptRequests, + deferWorkHistoryRows, + selectDeferredWorkPage, + encodeThreadHistoryCursor, olderHistoryPagePolicy, selectHistoryPageFromCursor, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, + THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT, THREAD_HISTORY_TURN_SNAPSHOT_ROW_LIMIT, } from "./threadHistoryPaging.ts"; @@ -416,6 +420,7 @@ export interface ProjectionStoreV2Shape { cursor: string, throughEntryId?: string, conversationOnly?: boolean, + workMode?: "deferred" | "work", ) => Effect.Effect; readonly getTimelinePage: ( threadId: ThreadId, @@ -611,6 +616,7 @@ export interface ProjectionStoreV2Shape { options: { readonly rowLimit: number; readonly userTurnLimit?: number | undefined; + readonly deferWork?: boolean | undefined; readonly anchorItemId?: TurnItemId | undefined; readonly anchorThreadId?: ThreadId | undefined; readonly requiredRunId?: RunId | undefined; @@ -625,6 +631,14 @@ export interface ProjectionStoreV2Shape { >; } +type ProjectionWindow = Omit< + Parameters[1], + "anchorThreadId" +> & { + readonly suppressLocal?: boolean | undefined; + readonly historyAnchor?: { readonly threadId: ThreadId; readonly itemId: TurnItemId } | undefined; +}; + export class ProjectionStoreV2 extends Context.Service()( "t3/orchestration-v2/ProjectionStore/ProjectionStoreV2", ) {} @@ -1796,6 +1810,11 @@ export const layer: Layer.Layer = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const inlineWorkOutput = sql`item.type = 'dynamic_tool' AND ( + item.payload_json LIKE '%htmlRender%' OR item.payload_json LIKE ${`%${MCP_APP_OUTPUT_KEY}%`} + OR item.payload_json LIKE '%"viewedImagePath"%' + OR item.payload_json LIKE '%"toolName":"%delegate_task"%')`; + const keepRecordedRunField = (payload: Statement.Fragment, path: string) => sql` CASE WHEN json_type(excluded.payload_json, ${path}) IS NULL @@ -2676,21 +2695,26 @@ export const layer: Layer.Layer = ); // Share selection so counting discarded ancestry preserves payload-read positions. - const retainedWindowRows = ( + const retainedWindowRows = Effect.fn("ProjectionStore.retainedWindowRows")(function* ( threadId: ThreadId, - window: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - }, - ) => sql` + window: ProjectionWindow, + ) { + const eligible = sql` WITH eligible AS NOT MATERIALIZED ( SELECT item.rowid AS item_rowid, item.payload_json, item.ordinal, item.turn_item_id, - item.run_id, item.node_id, item.type + item.run_id, item.node_id, item.type, item.status, run.status AS run_status, + attempt.attempt_id, attempt.status AS attempt_status FROM orchestration_v2_projection_turn_items AS item LEFT JOIN orchestration_v2_projection_runs AS run ON run.run_id = item.run_id + LEFT JOIN orchestration_v2_projection_nodes AS node + ON ${window.deferWork ? 1 : 0} = 1 AND node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt + ON ${window.deferWork ? 1 : 0} = 1 AND attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + ) WHERE item.thread_id = ${threadId} AND item.ordinal <= COALESCE( ( @@ -2749,7 +2773,10 @@ export const layer: Layer.Layer = AND request.type = 'run_interrupt_request' ) ) - ), run_starts AS ( + )`; + // Resolve anchors separately: repeated CTE expansion exceeds SQLite's subquery limit. + const anchors = yield* sql<{ readonly ordinal: number; readonly latest: number | null }>` + ${eligible}, run_starts AS ( -- A wake (PR watch, task completion) starts its run with a -- notification instead of a user message. Read the first -- input of each run, newest run first, and add the @@ -2813,6 +2840,49 @@ export const layer: Layer.Layer = ELSE 0 END AS ordinal FROM user_anchors + ) + SELECT ordinal, (SELECT MAX(ordinal) FROM user_anchors) AS latest FROM boundary + `; + const boundary = anchors[0]!; + return sql` + ${eligible}, blocked_runs AS MATERIALIZED ( + SELECT DISTINCT run_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = ${threadId} AND run_id IS NOT NULL + AND ${window.deferWork ? 1 : 0} = 1 + AND run_id IN ( + SELECT run_id FROM eligible + WHERE ordinal >= ${boundary.ordinal} AND run_id IS NOT NULL + ) + AND (type IN ('run_interrupt_request', 'run_interrupt_result') + OR (type = 'error' AND status = 'failed' AND parent_item_id IS NULL)) + ), work_segments AS ( + SELECT item_rowid, ordinal, turn_item_id, run_id, type, attempt_id, + MAX(CASE WHEN type = 'user_message' THEN ordinal ELSE 0 END) + OVER (PARTITION BY run_id ORDER BY ordinal) AS segment, + CASE WHEN status = 'completed' AND (run_status = 'completed' OR attempt_status = 'superseded') + AND type IN ('command_execution', 'reasoning', 'file_change', 'file_search', 'web_search', 'dynamic_tool') + AND NOT (${inlineWorkOutput}) + AND run_id NOT IN (SELECT run_id FROM blocked_runs) + THEN 1 ELSE 0 END AS can_defer + FROM eligible AS item + WHERE ${window.deferWork ? 1 : 0} = 1 + AND ${boundary.latest === null ? 0 : 1} = 1 + AND ordinal >= ${boundary.ordinal} + ), deferred_work AS ( + SELECT run_id, segment, attempt_id, MIN(ordinal) AS first_ordinal, + MAX(ordinal) AS last_ordinal, COUNT(*) AS item_count + FROM work_segments WHERE can_defer = 1 + GROUP BY run_id, segment, attempt_id HAVING COUNT(*) > ${THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT} + ), window_items AS NOT MATERIALIZED ( + SELECT item_rowid, ordinal, turn_item_id, run_id, type + FROM eligible WHERE ${window.deferWork ? 1 : 0} = 0 + OR ${boundary.latest === null ? 1 : 0} = 1 + UNION ALL + SELECT item.item_rowid, item.ordinal, item.turn_item_id, item.run_id, item.type + FROM work_segments item LEFT JOIN deferred_work deferred + ON deferred.run_id = item.run_id AND deferred.segment = item.segment + AND deferred.attempt_id IS item.attempt_id + WHERE item.can_defer = 0 OR deferred.run_id IS NULL OR item.ordinal = deferred.first_ordinal ), latest_user_turn AS ( -- Mirrors selectOlderTimelinePage: whole turns while the -- newest user turn fits the turn row budget, otherwise the @@ -2820,8 +2890,8 @@ export const layer: Layer.Layer = SELECT COUNT(*) AS row_count FROM ( SELECT 1 - FROM eligible - WHERE ordinal >= (SELECT MAX(ordinal) FROM user_anchors) + FROM window_items + WHERE ordinal >= ${boundary.latest} LIMIT ${THREAD_HISTORY_PAGE_POLICY.maxTurnItems + 1} ) ), selected AS ( @@ -2829,8 +2899,8 @@ export const layer: Layer.Layer = -- order. A turn window can hold megabytes of tool output, and -- carrying it through the union and sort cost about a second. SELECT item_rowid, ordinal, turn_item_id, run_id, type - FROM eligible - WHERE ordinal >= (SELECT ordinal FROM boundary) + FROM window_items + WHERE ordinal >= ${boundary.ordinal} ORDER BY ordinal DESC, turn_item_id DESC LIMIT CASE WHEN ${window.rowLimit} = 0 THEN 0 @@ -2863,17 +2933,12 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ) `; + }); const readCanonicalProjection = Effect.fn("ProjectionStore.readCanonicalProjection")( function* ( threadId: ThreadId, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - }, + window?: ProjectionWindow, fields?: ReadonlyArray, filter?: ProjectionRecordFilter, ) { @@ -2888,7 +2953,12 @@ export const layer: Layer.Layer = return yield* new ProjectionStoreThreadNotFoundError({ threadId }); } - const boundedTurnItemRows = + type WindowPayloadRow = PayloadRow & { + readonly work_last_id?: string | null; + readonly work_last_ordinal?: number | null; + readonly work_item_count?: number | null; + }; + const boundedTurnItemRows: ReadonlyArray = (fields !== undefined && !fields.includes("turnItems")) || (window === undefined && matchesNothing(filter?.turnItemTypes, filter?.turnItemStatuses, filter?.turnItemRunIds)) @@ -2905,18 +2975,51 @@ export const layer: Layer.Layer = ORDER BY ordinal ASC, turn_item_id ASC ` : yield* sql` - ${retainedWindowRows(threadId, window)} + ${yield* retainedWindowRows(threadId, window)} -- CROSS JOIN keeps the sorted IDs as the outer loop, so SQLite -- skips sorting again once the payloads are attached. Carry the -- rowid through selection to avoid a second string-key index lookup. - SELECT item.payload_json + SELECT item.payload_json, tail.turn_item_id AS work_last_id, + deferred.last_ordinal AS work_last_ordinal, deferred.item_count AS work_item_count FROM retained CROSS JOIN orchestration_v2_projection_turn_items AS item ON item.rowid = retained.item_rowid + LEFT JOIN deferred_work deferred ON deferred.run_id = item.run_id + AND deferred.first_ordinal = item.ordinal + LEFT JOIN orchestration_v2_projection_turn_items tail + ON tail.turn_item_id = ( + SELECT turn_item_id FROM orchestration_v2_projection_turn_items + WHERE thread_id = item.thread_id AND ordinal = deferred.last_ordinal + ORDER BY turn_item_id DESC LIMIT 1 + ) ORDER BY retained.ordinal ASC, retained.turn_item_id ASC `; // Decode items once for cohort ids and projection; repeated parsing duplicates retained tool output. - const turnItems = yield* decodeRows(decodeTurnItemPayload, threadId)(boundedTurnItemRows); + const decodedTurnItems = yield* decodeRows( + decodeTurnItemPayload, + threadId, + )(boundedTurnItemRows); + const turnItems = decodedTurnItems.map((item, index) => { + const row = boundedTurnItemRows[index]!; + return row.work_last_id == null || + row.work_item_count == null || + row.work_last_ordinal == null + ? item + : { + ...item, + deferredWork: { + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: threadId, + sourceItemId: TurnItemId.make(row.work_last_id), + position: row.work_last_ordinal, + inclusive: true, + }), + itemCount: row.work_item_count, + lastOrdinal: row.work_last_ordinal, + }, + }; + }); const cohortJson = (field: string) => JSON.stringify( turnItems.flatMap((payload) => { @@ -3438,32 +3541,14 @@ export const layer: Layer.Layer = const readProjection: ( threadId: ThreadId, seenThreadIds: ReadonlySet, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - readonly historyAnchor?: - | { readonly threadId: ThreadId; readonly itemId: TurnItemId } - | undefined; - }, + window?: ProjectionWindow, ) => Effect.Effect = Effect.fn( "ProjectionStore.readProjection", )( function* ( threadId: ThreadId, seenThreadIds: ReadonlySet, - window?: { - readonly rowLimit: number; - readonly userTurnLimit?: number | undefined; - readonly anchorItemId?: TurnItemId | undefined; - readonly requiredRunId?: RunId | undefined; - readonly suppressLocal?: boolean | undefined; - readonly historyAnchor?: - | { readonly threadId: ThreadId; readonly itemId: TurnItemId } - | undefined; - }, + window?: ProjectionWindow, ) { const localWindow = window?.suppressLocal === true || @@ -3569,6 +3654,7 @@ export const layer: Layer.Layer = return { rowLimit: window.rowLimit, userTurnLimit: window.userTurnLimit, + deferWork: window.deferWork, requiredRunId: forkedFrom.runId, suppressLocal: anchor === undefined || anchorIsInDescendant, ...(anchor === undefined || anchorIsInDescendant @@ -3591,7 +3677,7 @@ export const layer: Layer.Layer = .length >= Math.max(window.userTurnLimit, THREAD_HISTORY_PAGE_POLICY.maxUserTurns) ) { const counted = yield* sql<{ readonly inherited_rows: number | null }>` - ${retainedWindowRows(forkedFrom.threadId, { + ${yield* retainedWindowRows(forkedFrom.threadId, { ...sourceWindow, rowLimit: sourceWindow.suppressLocal ? 0 : sourceWindow.rowLimit, })} @@ -5196,7 +5282,7 @@ export const layer: Layer.Layer = type TimelineIndexItem = Pick< OrchestrationV2TurnItem, - "id" | "threadId" | "runId" | "nodeId" | "type" + "id" | "threadId" | "runId" | "nodeId" | "parentItemId" | "type" | "status" | "ordinal" > & { readonly inputIntent?: Extract< OrchestrationV2TurnItem, @@ -5204,6 +5290,11 @@ export const layer: Layer.Layer = >["inputIntent"]; readonly createdBy?: "user" | "agent" | "system"; readonly messageId?: MessageId; + readonly runStatus?: string | undefined; + readonly attemptId?: string | null; + readonly attemptStatus?: string | null; + readonly persistentOutput?: boolean; + readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; type TimelineIndexRow = { readonly sourceThreadId: ThreadId; @@ -5215,6 +5306,7 @@ export const layer: Layer.Layer = const readTimelineIndex: ( threadId: ThreadId, seen: ReadonlySet, + includePersistentOutput?: boolean, ) => Effect.Effect< { readonly records: ProjectionRecords<"runs" | "attempts">; @@ -5223,28 +5315,58 @@ export const layer: Layer.Layer = }, ProjectionStoreV2Error > = Effect.fn("ProjectionStore.readTimelineIndex")( - function* (threadId: ThreadId, seen: ReadonlySet) { + function* (threadId: ThreadId, seen: ReadonlySet, includePersistentOutput = false) { const records = yield* getThreadRecords(threadId, ["runs", "attempts"]); const rows = yield* sql<{ turn_item_id: string; run_id: string | null; node_id: string | null; + parent_item_id: string | null; type: OrchestrationV2TurnItem["type"]; input_intent: NonNullable | null; created_by: "user" | "agent" | "system" | null; message_id: string | null; - }>`SELECT turn_item_id, run_id, node_id, type, - CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.inputIntent') END AS input_intent, - CASE WHEN type = 'user_message' THEN json_extract(payload_json, '$.createdBy') END AS created_by, - CASE WHEN type IN ('user_message', 'assistant_message') THEN json_extract(payload_json, '$.messageId') END AS message_id - FROM orchestration_v2_projection_turn_items WHERE thread_id = ${threadId} - ORDER BY ordinal ASC, turn_item_id ASC`; + status: OrchestrationV2TurnItem["status"]; + ordinal: number; + persistent_output: number; + attempt_id: string | null; + attempt_status: string | null; + }>`SELECT item.turn_item_id, item.run_id, item.node_id, item.parent_item_id, item.type, item.status, item.ordinal, + ${includePersistentOutput ? sql`attempt.attempt_id` : sql`NULL`} AS attempt_id, + ${includePersistentOutput ? sql`attempt.status` : sql`NULL`} AS attempt_status, + ${ + includePersistentOutput ? sql`CASE WHEN ${inlineWorkOutput} THEN 1 ELSE 0 END` : sql`0` + } AS persistent_output, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.inputIntent') END AS input_intent, + CASE WHEN item.type = 'user_message' THEN json_extract(item.payload_json, '$.createdBy') END AS created_by, + CASE WHEN item.type IN ('user_message', 'assistant_message') THEN json_extract(item.payload_json, '$.messageId') END AS message_id + FROM orchestration_v2_projection_turn_items AS item + ${ + includePersistentOutput + ? sql`LEFT JOIN orchestration_v2_projection_nodes AS node ON node.node_id = item.node_id + LEFT JOIN orchestration_v2_projection_run_attempts AS attempt ON attempt.attempt_id = ( + SELECT attempt_id FROM orchestration_v2_projection_run_attempts + WHERE run_id = item.run_id AND root_node_id = COALESCE(node.root_node_id, item.node_id) + ORDER BY attempt_ordinal DESC LIMIT 1 + )` + : sql`` + } + WHERE item.thread_id = ${threadId} + ORDER BY item.ordinal ASC, item.turn_item_id ASC`; + const runStatuses = new Map(records.runs.map((run) => [String(run.id), run.status])); const items: Array = rows.map((row) => ({ id: TurnItemId.make(row.turn_item_id), threadId, runId: row.run_id === null ? null : RunId.make(row.run_id), nodeId: row.node_id === null ? null : NodeId.make(row.node_id), + parentItemId: row.parent_item_id === null ? null : TurnItemId.make(row.parent_item_id), type: row.type, + status: row.status, + ordinal: row.ordinal, + runStatus: row.run_id === null ? undefined : runStatuses.get(row.run_id), + attemptId: row.attempt_id, + attemptStatus: row.attempt_status, + persistentOutput: row.persistent_output === 1, ...(row.input_intent === null ? {} : { inputIntent: row.input_intent }), ...(row.created_by === null ? {} : { createdBy: row.created_by }), ...(row.message_id === null ? {} : { messageId: MessageId.make(row.message_id) }), @@ -5263,7 +5385,11 @@ export const layer: Layer.Layer = const visible = local.filter((row) => isVisible(row.item)); const fork = records.thread.forkedFrom; if (fork?.type !== "run" || seen.has(fork.threadId)) return { records, local, visible }; - const source = yield* readTimelineIndex(fork.threadId, new Set([...seen, threadId])); + const source = yield* readTimelineIndex( + fork.threadId, + new Set([...seen, threadId]), + includePersistentOutput, + ); const sourceRun = source.records.runs.find((run) => run.id === fork.runId); let inherited: Array = []; if (sourceRun !== undefined) { @@ -5329,11 +5455,12 @@ export const layer: Layer.Layer = cursor, throughEntryId, conversationOnly, + workMode, ) => sql .withTransaction( Effect.gen(function* () { - const index = yield* readTimelineIndex(threadId, new Set()); + const index = yield* readTimelineIndex(threadId, new Set(), workMode !== undefined); if (index.records.thread.deletedAt !== null) return yield* new ProjectionStoreThreadNotFoundError({ threadId }); const revisions = yield* sql<{ @@ -5347,21 +5474,32 @@ export const layer: Layer.Layer = position, canonicalPosition: position, })); + const historyItems = workMode === "deferred" ? deferWorkHistoryRows(indexed) : indexed; const selection = yield* Effect.try({ try: () => { const policy = olderHistoryPagePolicy({ items: indexed, cursor, throughEntryId }); - const page = selectHistoryPageFromCursor({ - items: indexed, - cursor, - snapshotSequence, - rowEncodedBytes: () => 0, - policy, - }); + const page = + workMode === "work" + ? selectDeferredWorkPage({ + items: indexed, + cursor, + throughEntryId, + snapshotSequence, + rowEncodedBytes: () => 0, + }) + : selectHistoryPageFromCursor({ + items: historyItems, + cursor, + snapshotSequence, + rowEncodedBytes: () => 0, + policy, + }); return { policy, page }; }, catch: (cause) => new ProjectionStoreReadError({ threadId, cause }), }); - const { policy, page } = selection; + const { page } = selection; + const policy = workMode === "work" ? THREAD_HISTORY_PAGE_POLICY : selection.policy; const end = (page.items.at(-1)?.canonicalPosition ?? -1) + 1; const hasUserTurns = index.visible .slice(0, end) @@ -5400,7 +5538,11 @@ export const layer: Layer.Layer = visibility: row.visibility, sourceThreadId: row.sourceThreadId, sourceItemId: row.sourceItemId, - item: projectTurnItemForWire(item), + item: projectTurnItemForWire( + row.item.deferredWork === undefined + ? item + : { ...item, deferredWork: row.item.deferredWork }, + ), }; }), ); @@ -5411,7 +5553,10 @@ export const layer: Layer.Layer = : page.items, ); const bounded = selectRecentTimelineWindow({ items, snapshotSequence, policy }); - const requests = yield* materialize(missingInterruptRequests(indexed, bounded.items)); + const requests = + workMode === "work" + ? [] + : yield* materialize(missingInterruptRequests(indexed, bounded.items)); const pageItems = [...requests, ...bounded.items].map((row, position) => ({ ...row, position, @@ -5517,6 +5662,7 @@ export const layer: Layer.Layer = const projection = yield* readProjection(threadId, new Set(), { rowLimit: options.rowLimit, userTurnLimit: options.userTurnLimit, + deferWork: options.deferWork, ...(historyAnchor?.threadId === threadId ? { anchorItemId: historyAnchor.itemId } : {}), diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index 917fdc807c2f8..615ea352f25af 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -1278,10 +1278,16 @@ const make = Effect.gen(function* () { }), dispatch, getThreadHistoryPage: Effect.fn("ThreadManagementService.getThreadHistoryPage")( - function (threadId, cursor, throughEntryId, conversationOnly) { + function (threadId, cursor, throughEntryId, conversationOnly, workMode) { return ensureProjectionTranscript(threadId).pipe( Effect.andThen( - orchestrator.getThreadHistoryPage(threadId, cursor, throughEntryId, conversationOnly), + orchestrator.getThreadHistoryPage( + threadId, + cursor, + throughEntryId, + conversationOnly, + workMode, + ), ), ); }, diff --git a/apps/server/src/orchestration-v2/http.ts b/apps/server/src/orchestration-v2/http.ts index 9d3e583b832e0..61ff54d0c794f 100644 --- a/apps/server/src/orchestration-v2/http.ts +++ b/apps/server/src/orchestration-v2/http.ts @@ -133,10 +133,12 @@ export const layer = HttpApiBuilder.group( anchorThreadId?: Parameters< typeof threadManagement.getThreadSnapshotWindow >[1]["anchorThreadId"], + deferWork = false, ) { return yield* threadManagement .getThreadSnapshotWindow(threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork, userTurnLimit: anchorItemId === undefined ? THREAD_HISTORY_PAGE_POLICY.maxUserTurns @@ -223,9 +225,12 @@ export const layer = HttpApiBuilder.group( Effect.fn("environment.orchestration.threadBoundedSnapshot")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); yield* requireEnvironmentScope(AuthOrchestrationReadScope); - const snapshot = yield* loadThreadSnapshotWindow(args.params.threadId).pipe( - traceLocalHandlerWork, - ); + const snapshot = yield* loadThreadSnapshotWindow( + args.params.threadId, + undefined, + undefined, + args.query.deferWork === "1", + ).pipe(traceLocalHandlerWork); const bounded = buildBoundedThreadProjection({ projection: snapshot.projection, snapshotSequence: snapshot.snapshotSequence, @@ -261,6 +266,11 @@ export const layer = HttpApiBuilder.group( args.query.cursor, args.query.throughEntryId, args.query.view === "conversation", + args.query.view === "work" + ? "work" + : args.query.deferWork === "1" + ? "deferred" + : undefined, ) .pipe( Effect.map((page) => diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts index 38ad3b5c7de13..f4e54000222fc 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.test.ts @@ -12,12 +12,15 @@ import * as DateTime from "effect/DateTime"; import { buildBoundedThreadProjection, + deferWorkHistoryRows, boundedTimelineEncodedBytes, computeLatestLocalTurnOrdinal, + decodeThreadHistoryCursor, encodeThreadHistoryCursor, projectedRowBoundedSnapshotEncodedBytes, projectedRowEncodedBytes, selectHistoryPageFromCursor, + selectDeferredWorkPage, selectRecentTimelineWindow, THREAD_HISTORY_PAGE_POLICY, } from "./threadHistoryPaging.ts"; @@ -155,6 +158,76 @@ function makeProjection(visibleTurnItems: OrchestrationV2ProjectedTurnItem[]) { } describe("threadHistoryPaging", () => { + it("defers completed work above 150 items and keeps conversation and persistent rows", () => { + const work = Array.from({ length: 151 }, (_, index) => { + const row = makeRow(index); + return { ...row, item: { ...row.item, runId: RUN, runStatus: "completed" } }; + }); + expect(deferWorkHistoryRows(work.slice(0, 150))).toHaveLength(150); + const deferredWork = deferWorkHistoryRows(work)[0]!.item.deferredWork!; + expect(deferredWork.itemCount).toBe(151); + expect(decodeThreadHistoryCursor(deferredWork.cursor).p).toBe(151); + const superseded = work.map((row) => ({ + ...row, + item: { ...row.item, runStatus: "running", attemptId: "old", attemptStatus: "superseded" }, + })); + const active = work.map((row, index) => ({ + ...row, + sourceItemId: TurnItemId.make(`active-${index}`), + position: row.position + work.length, + item: { ...row.item, runStatus: "running", attemptId: "active", attemptStatus: "running" }, + })); + const attempts = deferWorkHistoryRows([...superseded, ...active]); + expect(attempts[0]?.item.deferredWork?.itemCount).toBe(151); + expect(attempts.slice(1)).toEqual(active); + const compaction = { + ...work[0]!, + sourceItemId: TurnItemId.make("compaction"), + item: { + ...work[0]!.item, + type: "compaction" as const, + }, + }; + const persistent = { + ...work[0]!, + sourceItemId: TurnItemId.make("render"), + item: { + ...work[0]!.item, + type: "dynamic_tool" as const, + persistentOutput: true, + }, + }; + const deferred = deferWorkHistoryRows([ + ...work.slice(0, 30), + compaction, + persistent, + ...work.slice(30), + ]); + expect(deferred.map((row) => row.sourceItemId)).toEqual(["item-0", "compaction", "render"]); + expect(deferred[0]?.item.deferredWork?.itemCount).toBe(151); + for (const runStatus of ["running", "failed", "interrupted", "cancelled"]) { + expect( + deferWorkHistoryRows(work.map((row) => ({ ...row, item: { ...row.item, runStatus } }))), + ).toHaveLength(151); + } + const interrupted = { + ...compaction, + item: { ...compaction.item, type: "run_interrupt_result" as const }, + }; + expect(deferWorkHistoryRows([...work, interrupted])).toHaveLength(152); + const failed = { + ...compaction, + item: { + ...compaction.item, + type: "error" as const, + status: "failed" as const, + nodeId: "root-node", + parentItemId: null, + }, + }; + expect(deferWorkHistoryRows([...work, failed])).toHaveLength(152); + }); + it("keeps a human turn whole up to the turn row budget and pages a longer one from inside", () => { const humanTurn = (length: number) => { const items = Array.from({ length }, (_, index) => makeRow(index)); @@ -394,6 +467,39 @@ describe("threadHistoryPaging", () => { expect(page.items.map((row) => row.sourceItemId)).toEqual(["item-3", "item-4"]); expect(page.hasMoreHistory).toBe(true); expect(page.nextCursor).not.toBeNull(); + const sparse = [makeRow(0), makeRow(10), makeRow(20)]; + const middle = selectHistoryPageFromCursor({ + items: sparse, + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 3, + sourceThreadId: THREAD, + sourceItemId: "item-hidden", + position: 15, + }), + snapshotSequence: 3, + }); + expect(middle.items.map((row) => row.sourceItemId)).toEqual(["item-0", "item-10"]); + const work = Array.from({ length: 80 }, (_, index) => { + const row = makeRow(index); + return { ...row, item: { ...row.item, runId: RUN, ordinal: index + 100 } }; + }); + const tail = work[75]!; + const deferred = selectDeferredWorkPage({ + items: work.filter((row) => row !== tail).map((row, position) => ({ ...row, position })), + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 3, + sourceThreadId: THREAD, + sourceItemId: tail.sourceItemId, + position: tail.item.ordinal, + inclusive: true, + }), + throughEntryId: work[0]!.sourceItemId, + snapshotSequence: 3, + }); + expect(deferred.items.map((row) => row.sourceItemId)).toEqual( + work.slice(0, 75).map((row) => row.sourceItemId), + ); + expect(deferred.nextCursor).toBeNull(); }); it("keeps cursor resolution stable when newer items append", () => { diff --git a/apps/server/src/orchestration-v2/threadHistoryPaging.ts b/apps/server/src/orchestration-v2/threadHistoryPaging.ts index 146436fc3ca57..7f1ea4db95d6f 100644 --- a/apps/server/src/orchestration-v2/threadHistoryPaging.ts +++ b/apps/server/src/orchestration-v2/threadHistoryPaging.ts @@ -19,6 +19,8 @@ export const THREAD_HISTORY_PAGE_POLICY = { maxEncodedBytes: 1_048_576, } as const; +export const THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT = 150; + export const OLDER_THREAD_USER_TURN_LIMIT = 20; export function isConversationHistoryItem(item: Pick): boolean { @@ -46,6 +48,7 @@ export type ThreadHistoryCursorPayload = { readonly st: string; readonly si: string; readonly p: number; + readonly inclusive?: boolean; }; type HistoryRow = Pick< @@ -58,6 +61,14 @@ type HistoryRow = Pick< readonly inputIntent?: string; readonly createdBy?: string; readonly runId?: string | null; + readonly status?: OrchestrationV2TurnItem["status"]; + readonly parentItemId?: string | null; + readonly ordinal?: number; + readonly runStatus?: string | undefined; + readonly attemptId?: string | null | undefined; + readonly attemptStatus?: string | null | undefined; + readonly persistentOutput?: boolean; + readonly deferredWork?: OrchestrationV2TurnItem["deferredWork"]; }; }; export type SelectTimelinePageResult = { @@ -114,6 +125,7 @@ export function encodeThreadHistoryCursor(input: { readonly sourceThreadId: ThreadId | string; readonly sourceItemId: TurnItemId | string; readonly position: number; + readonly inclusive?: boolean; }): string { return encodeCursorPayload({ v: 1, @@ -121,6 +133,7 @@ export function encodeThreadHistoryCursor(input: { st: String(input.sourceThreadId), si: String(input.sourceItemId), p: input.position, + ...(input.inclusive ? { inclusive: true } : {}), }); } @@ -153,7 +166,8 @@ export function decodeThreadHistoryCursor(cursor: string): ThreadHistoryCursorPa !("p" in parsed) || typeof parsed.p !== "number" || !Number.isInteger(parsed.p) || - parsed.p < 0 + parsed.p < 0 || + ("inclusive" in parsed && typeof parsed.inclusive !== "boolean") ) { throw new InvalidThreadHistoryCursorError(); } @@ -167,6 +181,109 @@ function isThreadHistoryTurnStart(item: HistoryRow["item"]): boolean { ); } +const DEFERRED_WORK_ITEM_TYPES = [ + "command_execution", + "reasoning", + "file_change", + "file_search", + "web_search", + "dynamic_tool", +] as const; + +function isDeferrableWorkItem(item: HistoryRow["item"]): boolean { + return ( + item.status === "completed" && + item.persistentOutput !== true && + DEFERRED_WORK_ITEM_TYPES.some((type) => type === item.type) + ); +} + +export function deferWorkHistoryRows(items: ReadonlyArray): Row[] { + const blocked = new Set( + items.flatMap(({ item }) => + item.runId != null && + (item.type === "run_interrupt_request" || + item.type === "run_interrupt_result" || + (item.type === "error" && item.status === "failed" && item.parentItemId == null)) + ? [item.runId] + : [], + ), + ); + const segments = new Map(); + const groups = new Map(); + for (const row of items) { + const item = row.item; + if (item.runId == null) continue; + if (item.type === "user_message") segments.set(item.runId, (segments.get(item.runId) ?? 0) + 1); + if ( + (item.runStatus !== "completed" && item.attemptStatus !== "superseded") || + blocked.has(item.runId) || + !isDeferrableWorkItem(item) + ) + continue; + const key = `${row.sourceThreadId}:${item.runId}:${segments.get(item.runId) ?? 0}:${item.attemptId ?? ""}`; + const group = groups.get(key) ?? []; + group.push(row); + groups.set(key, group); + } + const omitted = new Set(); + const anchors = new Map(); + for (const group of groups.values()) { + if (group.length <= THREAD_HISTORY_INLINE_WORK_ITEM_LIMIT) continue; + const first = group[0]!; + const last = group.at(-1)!; + for (const row of group.slice(1)) omitted.add(row); + anchors.set(first, { + ...first, + item: { + ...first.item, + deferredWork: { + cursor: encodeThreadHistoryCursor({ + snapshotSequence: 0, + sourceThreadId: last.sourceThreadId, + sourceItemId: last.sourceItemId, + position: last.item.ordinal ?? last.position, + inclusive: true, + }), + itemCount: group.length, + lastOrdinal: last.item.ordinal ?? last.position, + }, + }, + }); + } + return items.flatMap((row) => (omitted.has(row) ? [] : [anchors.get(row) ?? row])); +} + +export function selectDeferredWorkPage(input: { + readonly items: ReadonlyArray; + readonly cursor: string; + readonly throughEntryId?: string | undefined; + readonly snapshotSequence: number; + readonly rowEncodedBytes?: ((row: Row) => number) | undefined; +}): SelectTimelinePageResult { + const cursor = decodeThreadHistoryCursor(input.cursor); + const end = findCursorEndIndex(input.items, cursor); + const start = input.items.findIndex((row) => row.sourceItemId === input.throughEntryId); + if (start < 0 || start >= end) throw new InvalidThreadHistoryCursorError(); + const anchor = input.items[start]!; + const items = input.items + .slice(start, end) + .filter( + (row) => + row.sourceThreadId === anchor.sourceThreadId && + row.item.runId === anchor.item.runId && + row.item.attemptId === anchor.item.attemptId && + isDeferrableWorkItem(row.item), + ); + return selectOlderTimelinePage({ + items, + exclusiveEndIndex: items.length, + snapshotSequence: input.snapshotSequence, + rowEncodedBytes: input.rowEncodedBytes, + policy: THREAD_HISTORY_PAGE_POLICY, + }); +} + /** Notifications can start wake turns when they are the run's first input; match ProjectionStore run_starts. */ function threadHistoryTurnStarts(items: ReadonlyArray): ReadonlyArray { const startedRuns = new Set(); @@ -275,17 +392,23 @@ export function selectRecentTimelineWindow(input: { }); } -function findCursorIndex( - items: ReadonlyArray>, +function findCursorEndIndex( + items: ReadonlyArray, cursor: ThreadHistoryCursorPayload, ): number { const byIdentity = items.findIndex( (row) => String(row.sourceThreadId) === cursor.st && String(row.sourceItemId) === cursor.si, ); if (byIdentity !== -1) { - return byIdentity; + return byIdentity + (cursor.inclusive ? 1 : 0); } - return Math.min(cursor.p, items.length); + // Inclusive deferred cursors carry source ordinals; ordinary cursors carry visible positions. + const byPosition = items.findIndex((row) => + cursor.inclusive + ? String(row.sourceThreadId) === cursor.st && (row.item.ordinal ?? row.position) > cursor.p + : row.position >= cursor.p, + ); + return byPosition === -1 ? items.length : byPosition; } /** @@ -304,7 +427,7 @@ export function olderHistoryPagePolicy(input: { maxUserTurns: OLDER_THREAD_USER_TURN_LIMIT, }; if (input.throughEntryId === undefined) return policy; - const anchorIndex = findCursorIndex(input.items, decodeThreadHistoryCursor(input.cursor)); + const anchorIndex = findCursorEndIndex(input.items, decodeThreadHistoryCursor(input.cursor)); const targetIndex = input.items.findIndex( (row) => (row.item.messageId ?? row.item.id) === input.throughEntryId, ); @@ -337,7 +460,7 @@ export function selectHistoryPageFromCursor(input: { const decoded = decodeThreadHistoryCursor(input.cursor); return selectOlderTimelinePage({ items: input.items, - exclusiveEndIndex: findCursorIndex(input.items, decoded), + exclusiveEndIndex: findCursorEndIndex(input.items, decoded), snapshotSequence: input.snapshotSequence, ...(input.rowEncodedBytes === undefined ? {} : { rowEncodedBytes: input.rowEncodedBytes }), policy: olderHistoryPagePolicy(input), @@ -351,7 +474,7 @@ export function selectHistoryPageFromCursor(input: { */ export function missingInterruptRequests( items: ReadonlyArray, - page: ReadonlyArray>, + page: ReadonlyArray>, ): Row[] { const runs = new Set(); for (const row of page) { @@ -407,16 +530,7 @@ function localTurnItemsForVisibleWindow( projection: OrchestrationV2ThreadProjection, visible: ReadonlyArray, ): OrchestrationV2TurnItem[] { - const localIds = new Set(); - for (const row of visible) { - if (isLocalProjectedRow(projection, row)) { - localIds.add(String(row.sourceItemId)); - } - } - if (localIds.size === 0) { - return []; - } - return projection.turnItems.filter((item) => localIds.has(String(item.id))); + return visible.filter((row) => isLocalProjectedRow(projection, row)).map((row) => row.item); } function messagesForBoundedProjection( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index bea3b8a46097a..e1d6303b23506 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -640,6 +640,7 @@ function hasCompatibleOrchestrationProtocol(url: URL): boolean { function shouldUseBoundedThreadSnapshot(input: { readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; }): boolean { return input.acceptBoundedSnapshot === true; } @@ -773,6 +774,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs readonly afterSequence?: number; readonly requestCompletionMarker?: boolean; readonly acceptBoundedSnapshot?: boolean; + readonly acceptDeferredWork?: boolean; readonly acceptCompactTurnItems?: boolean; readonly acceptDedupedBodies?: boolean; readonly acceptCompactIds?: boolean; @@ -860,6 +862,7 @@ export const subscribeOrchestrationV2Thread = Effect.fn("ws.orchestrationV2.subs useBoundedSnapshot ? threadManagement.getThreadSnapshotWindow(input.threadId, { rowLimit: THREAD_HISTORY_SNAPSHOT_ROW_LIMIT, + deferWork: input.acceptDeferredWork === true, userTurnLimit: THREAD_HISTORY_PAGE_POLICY.maxUserTurns, }) : threadManagement.getThreadSnapshot(input.threadId) diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index aa082a268667d..3ee079809f14e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -2693,6 +2693,13 @@ describe("v2 run and attempt history", () => { runId, label: "Old command", tone: "tool" as const, + projectedItem: { + sourceItemId: "old-work-anchor", + item: { + type: "command_execution", + deferredWork: { cursor: "old-work", itemCount: 200, lastOrdinal: 200 }, + }, + } as never, }, }, { @@ -2765,6 +2772,7 @@ describe("v2 run and attempt history", () => { runId, label: "Superseded attempt", expanded: false, + deferredWorkItemIds: ["old-work-anchor"], }); const expandedRows = deriveMessagesTimelineRows({ @@ -2781,6 +2789,52 @@ describe("v2 run and attempt history", () => { "steer-user-entry", "active-assistant-entry", ]); + const settledRows = deriveMessagesTimelineRows({ + ...common, + latestRun: { ...common.latestRun, status: "completed", completedAt: "2026-01-01T00:00:08Z" }, + timelineEntries: timelineEntries.map((entry) => + entry.kind === "message" && entry.id === "active-assistant-entry" + ? { ...entry, message: { ...entry.message, streaming: false } } + : entry, + ), + expandedRunIds: new Set([runId]), + }); + expect(settledRows.find((row) => row.kind === "attempt-fold")).toMatchObject({ + deferredWorkItemIds: ["old-work-anchor"], + }); + expect( + settledRows + .filter((row) => row.kind === "turn-fold") + .flatMap((row) => row.deferredWorkItemIds ?? []), + ).not.toContain("old-work-anchor"); + const deferredTailRows = deriveMessagesTimelineRows({ + ...common, + timelineEntries: [ + timelineEntries[0]!, + timelineEntries[2]!, + timelineEntries[1]!, + ...timelineEntries.slice(3), + ].map((entry) => ({ + ...entry, + attempt: + entry.attempt.id === supersededAttemptId + ? { ...entry.attempt, completedAt: DateTime.makeUnsafe("2026-01-01T00:00:08Z") } + : entry.attempt, + ...(entry.id === "superseded-assistant-entry" + ? { + projectedItem: { + item: { + type: "assistant_message", + ordinal: 2, + status: "completed", + completedAt: DateTime.makeUnsafe("2026-01-01T00:00:03Z"), + }, + } as never, + } + : {}), + })), + }); + expect(deferredTailRows.some((row) => row.kind === "attempt-fold")).toBe(true); }); describe("a steer that supersedes an attempt", () => { @@ -3015,6 +3069,35 @@ describe("v2 run and attempt history", () => { }); }); +it.each([ + [undefined, ["first"]], + [["first"], ["second"]], + [["first"], []], + [ + ["first", "second"], + ["second", "first"], + ], + [undefined, []], +] as const)("keeps deferred fold anchors current from %j to %j", (before, after) => { + const row: Extract = { + kind: "turn-fold", + id: "fold", + runId: RunId.make("run"), + createdAt: "2026-01-01T00:00:00Z", + label: "Worked for 1m", + expanded: true, + ...(before === undefined ? {} : { deferredWorkItemIds: before }), + }; + const previous = computeStableMessagesTimelineRows([row], { result: [], byId: new Map() }); + const nextRow = { ...row, deferredWorkItemIds: after }; + const next = computeStableMessagesTimelineRows([nextRow], previous); + const fold = next.result[0]; + expect(fold?.kind === "turn-fold" ? (fold.deferredWorkItemIds ?? []) : null).toEqual(after); + expect( + computeStableMessagesTimelineRows([{ ...nextRow, deferredWorkItemIds: [...after] }], next), + ).toBe(next); +}); + describe("streaming v2 row projection", () => { function fixture(text = "") { const source = makeStreamingTimelineFixture(text); diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index ac9db2f7dd794..4ea9e852edfba 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -553,6 +553,7 @@ type MessagesTimelineRowContent = } | { kind: "turn-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -561,6 +562,7 @@ type MessagesTimelineRowContent = } | { kind: "attempt-fold"; + deferredWorkItemIds?: ReadonlyArray; id: string; createdAt: string; runId: RunId; @@ -709,6 +711,7 @@ interface TurnFold { } interface SupersededAttemptFold { + readonly deferredWorkItemIds: ReadonlyArray; readonly runId: RunId; readonly attemptId: RunAttemptId; readonly anchorEntryId: string; @@ -731,6 +734,18 @@ function completeSupersededReplyEntryIds( ) { continue; } + const previous = lastOutputByAttemptId.get(entry.attempt.id); + const deferredEnd = + previous?.kind === "work" + ? previous.entry.projectedItem?.item.deferredWork?.lastOrdinal + : undefined; + const ordinal = + entry.kind === "work" + ? entry.entry.projectedItem?.item.ordinal + : entry.kind === "message" + ? entry.projectedItem?.item.ordinal + : undefined; + if (deferredEnd !== undefined && ordinal !== undefined && deferredEnd > ordinal) continue; lastOutputByAttemptId.set(entry.attempt.id, entry); } const replyEntryIds = new Set(); @@ -790,6 +805,11 @@ function deriveSupersededAttemptFolds( const attempt = firstEntry?.attempt; if (firstEntry === undefined || attempt === undefined) continue; foldsByAnchorEntryId.set(firstEntry.id, { + deferredWorkItemIds: entries.flatMap((entry) => + entry.kind === "work" && entry.entry.projectedItem?.item.deferredWork + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ), runId: attempt.runId, attemptId: attempt.id, anchorEntryId: firstEntry.id, @@ -1398,6 +1418,9 @@ export function deriveMessagesTimelineRows(input: { liveSubagentEntryIds, completeSupersededReplyIds, ); + const supersededWorkItemIds = new Set( + [...supersededFoldsByAnchorEntryId.values()].flatMap((fold) => fold.deferredWorkItemIds), + ); const activeVisualResponseRunIds = deriveActiveVisualResponseRunIds({ timelineEntries: timelineEntries, unsettledRunId, @@ -1658,9 +1681,18 @@ export function deriveMessagesTimelineRows(input: { const turnFold = foldsByAnchorEntryId.get(timelineEntry.id); if (turnFold) { + const deferredWorkItemIds = timelineEntries.flatMap((entry) => + turnFold.entryIds.has(entry.id) && + entry.kind === "work" && + entry.entry.projectedItem?.item.deferredWork && + !supersededWorkItemIds.has(String(entry.entry.projectedItem.sourceItemId)) + ? [String(entry.entry.projectedItem.sourceItemId)] + : [], + ); nextRows.push({ kind: "turn-fold", id: `turn-fold:${turnFold.runId}`, + ...(deferredWorkItemIds.length > 0 ? { deferredWorkItemIds } : {}), createdAt: turnFold.createdAt, runId: turnFold.runId, label: turnFold.label, @@ -1676,6 +1708,7 @@ export function deriveMessagesTimelineRows(input: { if (supersededFold) { nextRows.push({ kind: "attempt-fold", + deferredWorkItemIds: supersededFold.deferredWorkItemIds, id: `attempt-fold:${supersededFold.attemptId}`, createdAt: supersededFold.createdAt, runId: supersededFold.runId, @@ -2326,14 +2359,18 @@ function isRowUnchanged(a: MessagesTimelineRow, b: MessagesTimelineRow): boolean ); } + case "attempt-fold": case "turn-fold": { const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; - } - - case "attempt-fold": { - const bf = b as typeof a; - return a.createdAt === bf.createdAt && a.label === bf.label && a.expanded === bf.expanded; + const anchors = a.deferredWorkItemIds ?? []; + const nextAnchors = bf.deferredWorkItemIds ?? []; + return ( + a.createdAt === bf.createdAt && + a.label === bf.label && + a.expanded === bf.expanded && + anchors.length === nextAnchors.length && + anchors.every((id, index) => id === nextAnchors[index]) + ); } case "context-compaction": { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b74f9edd595c9..8a621e47eaba7 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -1,3 +1,5 @@ +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { ToolCallDiffTarget } from "../../diffPanelStore"; import { AudioPreviewUnmountContext } from "../files/AudioPreview"; import { ThreadFindTimelineContext } from "./ThreadFindProvider"; @@ -36,6 +38,8 @@ import { use, useContext, useLayoutEffect, + useEffect, + useEffectEvent, useRef, useState, type ReactNode, @@ -52,6 +56,7 @@ import { type ChatFileAttachment, type TurnDiffSummary } from "../../types"; import { ArrowRightIcon, BotIcon, CircleAlertIcon, Minimize2Icon } from "lucide-react"; import { ChevronDown, ChevronRight } from "lucide"; import { MorphIcon } from "~/components/MorphIcon"; +import { Skeleton } from "../ui/skeleton"; import { ExpandedImagePreview } from "./ExpandedImagePreview"; import { CHAT_TIMELINE_ANCHOR_OFFSET, @@ -1517,7 +1522,7 @@ const TimelineRowContent = memo(function TimelineRowContent({ row }: { row: Time {row.kind === "thinking" ? : null} ) : null} - {row.kind === "turn-fold" ? : null} + {row.kind === "turn-fold" ? : null} {row.kind === "attempt-fold" ? : null} {row.kind === "context-compaction" ? : null} {row.kind === "message" && row.message.role === "user" ? : null} @@ -1609,48 +1614,123 @@ function ContextCompactionTimelineRow({ ); } +function useDeferredWorkLoading( + row: { expanded: boolean; deferredWorkItemIds?: ReadonlyArray }, + onToggle: () => void, +) { + const ctx = use(TimelineRowCtx); + const loadWork = useAtomCommand(threadEnvironment.loadEarlierHistory, { reportFailure: false }); + const ref = parseScopedThreadKey(ctx.routeThreadKey); + const environmentId = ref?.environmentId; + const threadId = ref?.threadId; + const anchorsKey = row.deferredWorkItemIds?.join("\n") ?? ""; + const [failed, setFailed] = useState(false); + const load = () => { + if (!anchorsKey || environmentId === undefined || threadId === undefined) return; + void Promise.all( + anchorsKey + .split("\n") + .map((workAnchorId) => loadWork({ environmentId, input: { threadId, workAnchorId } })), + ).then((results) => + setFailed( + results.some((result) => result._tag === "Failure" || result.value._tag === "error"), + ), + ); + }; + const loadOnExpand = useEffectEvent(load); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Restored expanded folds load their deferred server history on mount. + useEffect(() => { + if (row.expanded && anchorsKey && environmentId && threadId) loadOnExpand(); + }, [row.expanded, anchorsKey, environmentId, threadId]); + const error = anchorsKey && failed ? "Couldn't load work. Click to retry." : null; + const loading = ref !== null && row.expanded && anchorsKey !== "" && !failed; + const toggle = () => { + setFailed(false); + if (error && row.expanded) load(); + else onToggle(); + }; + + return { loading, error, toggle }; +} + +function WorkHistorySkeleton() { + return ( +
    + {[0, 1, 2].map((index) => ( + + ))} +
    + ); +} + function TurnFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleTurnFold(row.runId), + ); return ( -
    - - +
    +
    + + +
    + {loading ? : null}
    ); } function AttemptFoldTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); + const { loading, error, toggle } = useDeferredWorkLoading(row, () => + ctx.onToggleAttemptFold(row.attemptId), + ); return ( - +
    + + {loading ? : null} +
    ); } diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index b67da2cc8f6cb..45a070b9806b1 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -1284,7 +1284,7 @@ export function PullRequestDetailPanel({ openOnHostText={openOnHostText} onBack={onBack} /> -
    +
    {detail ? ( {!nativeStack && supportsStackActions && nativeStackQuery.error ? ( diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index de213de2f3c1c..f905376dc341a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -557,6 +557,20 @@ describe("V2 session presentation", () => { if (threadCreatedEntry?.kind === "work") { expect(threadCreatedEntry.entry.projectedItem?.item.type).toBe("thread_created"); } + expect( + deriveTimelineEntriesFromVisibleTurnItems({ + visibleTurnItems: [ + { + ...visibleTurnItems.at(-1)!, + item: { + ...workspacePreparationItem, + deferredWork: { cursor: "work-page", itemCount: 100, lastOrdinal: 107 }, + }, + }, + ], + optimisticMessages: [], + }).map((entry) => entry.id), + ).toEqual([workspacePreparationItem.id]); }); it.each(["pending", "running", "completed"] as const)( diff --git a/apps/web/src/session-logic.ts b/apps/web/src/session-logic.ts index 362d9fb9451a4..37d7ad27be5a7 100644 --- a/apps/web/src/session-logic.ts +++ b/apps/web/src/session-logic.ts @@ -601,7 +601,7 @@ export function deriveTimelineEntriesFromVisibleTurnItems( ); for (const row of input.visibleTurnItems) { const { item } = row; - if (turnItemIsWorkspacePreparation(item)) continue; + if (turnItemIsWorkspacePreparation(item) && !item.deferredWork) continue; if (item.type === "checkpoint") continue; if ( item.type === "user_message" && diff --git a/docs/internals/performance-regressions.md b/docs/internals/performance-regressions.md index 51760756c1085..4458f1af93461 100644 --- a/docs/internals/performance-regressions.md +++ b/docs/internals/performance-regressions.md @@ -15,7 +15,9 @@ The v2 checks pin these invariants: - Cold opens and history pages stop at about 1 MiB of encoded rows. A window that holds a user turn also stops at 10 user turns (20 on older pages) or 500 rows, so ordinary turns stay whole. Any other window, including the inside of one turn longer than 500 rows, stops at 75 rows. The - snapshot query applies the same rule before it decodes payloads. Required control records and + snapshot query applies the same rule before it decodes payloads. Clients can opt into deferring + large work groups until expansion. Messages and items outside those groups remain in normal + history paging. Required control records and the at-least-one-row rule can exceed the byte target; it is not a hard cap. - The bounded snapshot does not retain the full duplicate conversation-message table. - Older activity is fetched in bounded HTTP pages and merged without disturbing the live scroll @@ -32,8 +34,9 @@ The v2 checks pin these invariants: projects small can increase server allocations on repeated large tool updates. - Raw command output, dynamic-tool result bodies, and inline file-change bodies are omitted at the wire boundary, including small outputs. Explicit failure flags and bounded result IDs - preserve status and grouped action counts without shipping those bodies. Subagent prompts and - results, reasoning text, command lines, search results, and checkpoint file lists still ship. + preserve status and grouped action counts without shipping those bodies. Reasoning text, + command lines, and search results ship when their work group loads. Subagent prompts and results + and checkpoint control records still accompany the initial window. Persisted events remain complete; the existing diff endpoints still provide file content when requested. Explicit cross-environment transcript exports preserve full tool bodies so the destination diff --git a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts index b7cb95f703100..e3e9b5bb57fec 100644 --- a/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts +++ b/packages/client-runtime/src/state/boundedThreadSnapshotHttp.ts @@ -30,7 +30,12 @@ export const fetchEnvironmentBoundedThreadSnapshot = Effect.fn( }) { const endpoint = { params: { threadId: input.threadId }, - query: { compactTurnItems: "1", dedupeBodies: "1", compactIds: "1" }, + query: { + compactTurnItems: "1", + dedupeBodies: "1", + compactIds: "1", + deferWork: "1", + }, }; return yield* executeAuthenticatedEnvironmentHttpRequest({ ...input, diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts index e0680c17c46f8..f6ba3e9f0c1a7 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.test.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.test.ts @@ -219,8 +219,9 @@ describe("applyOrchestrationV2ProjectionEvent", () => { expect(next?.visibleTurnItems[0]).toBe(visibleTurnItems[0]); }); - it("replaces only the updated visible item when membership is unchanged", () => { - const first = commandItem("item-first", "first"); + it("replaces only the updated visible item and retains its deferred work cursor", () => { + const deferredWork = { cursor: "remaining-work", itemCount: 100, lastOrdinal: 101 }; + const first = { ...commandItem("item-first", "first"), deferredWork }; const second = commandItem("item-second", "second"); const firstRow = { position: 0, @@ -253,9 +254,10 @@ describe("applyOrchestrationV2ProjectionEvent", () => { } as OrchestrationV2DomainEvent; const next = applyOrchestrationV2ProjectionEvent(projection, event); + expect(next?.visibleTurnItems[0]?.item.deferredWork).toEqual(deferredWork); expect(next?.visibleTurnItems).not.toBe(projection.visibleTurnItems); expect(next?.visibleTurnItems[0]).not.toBe(firstRow); - expect(next?.visibleTurnItems[0]?.item).toBe(updated); + expect(next?.visibleTurnItems[0]?.item).toEqual({ ...updated, deferredWork }); expect(next?.visibleTurnItems[1]).toBe(secondRow); }); diff --git a/packages/client-runtime/src/state/orchestrationV2Projection.ts b/packages/client-runtime/src/state/orchestrationV2Projection.ts index ffe9eb14dfa2d..fbba7d1bd9dd4 100644 --- a/packages/client-runtime/src/state/orchestrationV2Projection.ts +++ b/packages/client-runtime/src/state/orchestrationV2Projection.ts @@ -370,12 +370,15 @@ export function applyOrchestrationV2ProjectionEvent( case "plan.updated": return { ...base, plans: upsertEntity(base.plans, event.payload) }; case "turn-item.updated": { - const payload = restoreTurnItemBodies(event.payload, latestBodies(projection)); + let payload = restoreTurnItemBodies(event.payload, latestBodies(projection)); // Streamed updates land on the newest items, so search from the end. let itemIndex = projection.turnItems.length - 1; while (itemIndex >= 0 && projection.turnItems[itemIndex]!.id !== payload.id) { itemIndex -= 1; } + const deferredWork = + itemIndex === -1 ? undefined : projection.turnItems[itemIndex]?.deferredWork; + if (deferredWork !== undefined) payload = { ...payload, deferredWork }; if ( partialTimeline && itemIndex === -1 && diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 6070638c57a66..0f34a926676c8 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -64,6 +64,7 @@ import * as ThreadHistoryController from "./threadHistoryController.ts"; export type LoadEarlierThreadHistoryInput = { readonly threadId: ThreadId; readonly throughEntryId?: string; + readonly workAnchorId?: string; }; export function createThreadEnvironmentAtoms( @@ -90,6 +91,7 @@ export function createThreadEnvironmentAtoms( supervisor.target.environmentId, input.threadId, input.throughEntryId, + input.workAnchorId, ); }); diff --git a/packages/client-runtime/src/state/threadHistoryController.ts b/packages/client-runtime/src/state/threadHistoryController.ts index c9ae1843ff27d..1c8818fd54db1 100644 --- a/packages/client-runtime/src/state/threadHistoryController.ts +++ b/packages/client-runtime/src/state/threadHistoryController.ts @@ -13,7 +13,10 @@ export type ThreadHistoryLoadEarlierResult = | { readonly _tag: "error"; readonly message: string }; export type ThreadHistoryHandler = { - readonly loadEarlier: (throughEntryId?: string) => Effect.Effect; + readonly loadEarlier: ( + throughEntryId?: string, + workAnchorId?: string, + ) => Effect.Effect; readonly reconcilePendingWork: () => Effect.Effect; }; @@ -36,6 +39,7 @@ export class ThreadHistoryController extends Context.Service< environmentId: EnvironmentId, threadId: ThreadId, throughEntryId?: string, + workAnchorId?: string, ) => Effect.Effect; readonly reconcilePendingWork: ( environmentId: EnvironmentId, @@ -81,14 +85,14 @@ export const layer: Layer.Layer = Layer.effect( }); }), loadEarlier: Effect.fn("ThreadHistoryController.loadEarlier")( - function (environmentId, threadId, throughEntryId) { + function (environmentId, threadId, throughEntryId, workAnchorId) { return Ref.get(handlers).pipe( Effect.flatMap((current) => { const handler = current.get(threadKey({ environmentId, threadId })); if (handler === undefined) { return Effect.succeed({ _tag: "noop" } satisfies ThreadHistoryLoadEarlierResult); } - return handler.loadEarlier(throughEntryId); + return handler.loadEarlier(throughEntryId, workAnchorId); }), ); }, diff --git a/packages/client-runtime/src/state/threadHistoryHttp.ts b/packages/client-runtime/src/state/threadHistoryHttp.ts index a9714172e44bd..f0ca836248aff 100644 --- a/packages/client-runtime/src/state/threadHistoryHttp.ts +++ b/packages/client-runtime/src/state/threadHistoryHttp.ts @@ -19,7 +19,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( readonly threadId: ThreadId; readonly cursor: string; readonly throughEntryId?: string | undefined; - readonly view?: "conversation" | "activity" | undefined; + readonly view?: "conversation" | "activity" | "work" | undefined; readonly signer: Option.Option; readonly remoteAuthorization?: Option.Option; readonly timeoutMs?: number; @@ -29,6 +29,7 @@ export const fetchEnvironmentThreadHistoryPage = Effect.fn( query: { cursor: input.cursor, compactIds: "1", + ...(input.throughEntryId === undefined ? { deferWork: "1" } : {}), ...(input.view === undefined ? {} : { view: input.view }), ...(input.throughEntryId === undefined ? {} : { throughEntryId: input.throughEntryId }), }, diff --git a/packages/client-runtime/src/state/threadHistoryMerge.test.ts b/packages/client-runtime/src/state/threadHistoryMerge.test.ts index 4dd4e7cef5210..59e1ea3652e52 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.test.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.test.ts @@ -64,6 +64,29 @@ describe("threadHistoryMerge", () => { expect(complete.visibleTurnItems[1]?.item).toBe(updated.item); expect(complete.visibleTurnItems.map((entry) => entry.position)).toEqual([0, 1, 2, 3, 4]); expect(new Set(complete.turnItems.map((item) => item.id)).size).toBe(5); + const anchorItem = { + ...updated.item, + deferredWork: { cursor: "work", itemCount: 4, lastOrdinal: 5 }, + }; + const deferred = { + ...live, + turnItems: live.turnItems.map((item) => (item.id === anchorItem.id ? anchorItem : item)), + visibleTurnItems: live.visibleTurnItems.map((entry) => + entry.item.id === anchorItem.id ? { ...entry, item: anchorItem } : entry, + ), + }; + const loadedWork = mergeOlderHistoryIntoProjection(deferred, [row(1), row(2), row(3)], { + anchorId: anchorItem.id, + nextCursor: null, + }); + expect(loadedWork.visibleTurnItems.map((entry) => entry.sourceItemId)).toEqual([ + "item-1", + "item-2", + "item-3", + "item-4", + ]); + expect(loadedWork.visibleTurnItems[0]?.item.title).toBe("newer live title"); + expect(loadedWork.visibleTurnItems[0]?.item.deferredWork).toBeUndefined(); const hidden = { ...partial, visibleTurnItems: partial.visibleTurnItems.filter( diff --git a/packages/client-runtime/src/state/threadHistoryMerge.ts b/packages/client-runtime/src/state/threadHistoryMerge.ts index 24840ee28420a..6fd678b66b129 100644 --- a/packages/client-runtime/src/state/threadHistoryMerge.ts +++ b/packages/client-runtime/src/state/threadHistoryMerge.ts @@ -52,11 +52,19 @@ function renumberVisible( export function mergeOlderHistoryIntoProjection( projection: OrchestrationV2ThreadProjection, olderItems: ReadonlyArray, + work?: { readonly anchorId: string; readonly nextCursor: string | null }, ): OrchestrationV2ThreadProjection { - if (olderItems.length === 0) { + if (olderItems.length === 0 && work === undefined) { return projection; } + const start = + work === undefined + ? -1 + : projection.visibleTurnItems.findIndex((row) => row.sourceItemId === work.anchorId); + const anchor = projection.visibleTurnItems[start]; + const deferred = anchor?.item.deferredWork; + if (work !== undefined && deferred === undefined) return projection; const currentByKey = new Map( projection.visibleTurnItems.map((row) => [projectedItemKey(row), row]), ); @@ -64,6 +72,15 @@ export function mergeOlderHistoryIntoProjection( const pageKeys = new Set(); const page: OrchestrationV2ProjectedTurnItem[] = []; for (const pageRow of olderItems) { + if ( + work && + anchor && + deferred && + (pageRow.sourceThreadId !== anchor.sourceThreadId || + pageRow.item.ordinal < anchor.item.ordinal || + pageRow.item.ordinal > deferred.lastOrdinal) + ) + continue; const key = projectedItemKey(pageRow); if (pageKeys.has(key)) continue; let row = currentByKey.get(key) ?? pageRow; @@ -94,11 +111,43 @@ export function mergeOlderHistoryIntoProjection( ) turnItemById.set(String(row.sourceItemId), row.item); } - if (page.length === 0) return projection; - const visible = [ + if (page.length === 0 && work === undefined) return projection; + let visible = [ ...page, ...projection.visibleTurnItems.filter((row) => !pageKeys.has(projectedItemKey(row))), ]; + if (work && anchor && deferred) { + let end = start + 1; + while (end < projection.visibleTurnItems.length) { + const row = projection.visibleTurnItems[end]!; + if ( + row.sourceThreadId !== anchor.sourceThreadId || + row.visibility === "synthetic" || + row.item.ordinal > deferred.lastOrdinal + ) + break; + end += 1; + } + const item = { ...anchor.item }; + if (work.nextCursor === null) delete item.deferredWork; + else item.deferredWork = { ...deferred, cursor: work.nextCursor }; + const updated = { ...anchor, item }; + const section = [ + ...page, + ...projection.visibleTurnItems + .slice(start, end) + .filter((row) => !pageKeys.has(projectedItemKey(row))), + ]; + const ordered = section + .map((row) => (row.sourceItemId === anchor.sourceItemId ? updated : row)) + .sort((left, right) => left.item.ordinal - right.item.ordinal); + visible = [ + ...projection.visibleTurnItems.slice(0, start), + ...ordered, + ...projection.visibleTurnItems.slice(end), + ]; + if (anchor.visibility === "local") turnItemById.set(String(updated.item.id), updated.item); + } if ( visible.length === projection.visibleTurnItems.length && visible.every((row, index) => row === projection.visibleTurnItems[index]) diff --git a/packages/client-runtime/src/state/threads-sync.test.ts b/packages/client-runtime/src/state/threads-sync.test.ts index 712eaffe77f40..c8c212db41ca1 100644 --- a/packages/client-runtime/src/state/threads-sync.test.ts +++ b/packages/client-runtime/src/state/threads-sync.test.ts @@ -22,6 +22,7 @@ import * as Option from "effect/Option"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Scope from "effect/Scope"; +import * as Scheduler from "effect/Scheduler"; import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import * as SubscriptionRef from "effect/SubscriptionRef"; @@ -269,6 +270,8 @@ const makeHarness = Effect.fn("TestEnvironmentThreads.makeHarness")(function* (o return { threadState, + loadWork: (anchorId: string) => + historyController.loadEarlier(TARGET.environmentId, THREAD_ID, undefined, anchorId), loadEarlier: (throughEntryId?: string) => historyController.loadEarlier(TARGET.environmentId, THREAD_ID, throughEntryId), reconcilePendingWork: historyController.reconcilePendingWork(TARGET.environmentId, THREAD_ID), @@ -688,7 +691,7 @@ describe("EnvironmentThreads", () => { }), ); - describe("Stop on cached work the server already finished (#18188)", () => { + describe("cached work (#18188)", () => { const at = DateTime.makeUnsafe("2026-06-20T00:00:00.000Z"); const parentThreadId = ThreadId.make("thread:parent"); const command = ( @@ -696,7 +699,7 @@ describe("EnvironmentThreads", () => { status: "running" | "completed", exitCode?: number, ordinal = id === "older-command" ? 1 : 2, - ): OrchestrationV2TurnItem => ({ + ): Extract => ({ id: TurnItemId.make(id), threadId: THREAD_ID, runId: null, @@ -723,6 +726,261 @@ describe("EnvironmentThreads", () => { sourceItemId: item.id, item, }); + const cachedItem = (item: OrchestrationV2TurnItem) => ({ + ...BASE_PROJECTION, + turnItems: [item], + visibleTurnItems: [localRow(item, 0)], + }); + const historyPage = ( + request: Parameters[0], + items: ReadonlyArray>, + nextCursor: string | null, + ) => + HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: CACHED_SNAPSHOT_SEQUENCE, + items, + nextCursor, + hasMoreHistory: nextCursor !== null, + }), + ); + it.effect("loads a long unwrapped turn through its user message and resumes failed pages", () => + Effect.gen(function* () { + const prompt = { + ...command("prompt", "completed", 0, 0), + type: "user_message" as const, + messageId: MessageId.make("prompt"), + inputIntent: "turn_start" as const, + createdBy: "user" as const, + creationSource: "web" as const, + text: "Do the work", + attachments: [], + }; + const work = Array.from({ length: 1_000 }, (_, index) => + command(`work-${index + 1}`, "completed", 0, index + 1), + ); + const older = [prompt, ...work.slice(0, -1)]; + const tail = work.at(-1)!; + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + cached: cachedItem(tail), + cachedHistory: { historyCursor: "1000", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "before-user") return Effect.succeed(historyPage(request, [], cursor)); + if (cursor === "925" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response(null, { status: 503 })), + ); + } + const end = Number(cursor); + expect(Number.isFinite(end)).toBe(true); + const start = Math.max(0, end - 75); + return Effect.succeed( + historyPage( + request, + older.slice(start, end).map(localRow), + start === 0 ? "before-user" : String(start), + ), + ); + }), + }); + expect((yield* harness.loadEarlier())._tag).toBe("error"); + expect(requests).toEqual(["1000", "925"]); + expect(yield* harness.loadEarlier()).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.map((row) => row.sourceItemId), + ).toEqual([prompt, ...work].map((item) => item.id)); + expect(state.history).toMatchObject({ + historyCursor: "before-user", + hasMoreHistory: true, + loading: false, + }); + expect(requests.slice(1, 3)).toEqual(["925", "925"]); + expect(yield* harness.loadEarlier()).toEqual({ + _tag: "error", + message: "History did not advance.", + }); + expect((yield* SubscriptionRef.get(harness.threadState)).history.loading).toBe(false); + }), + ); + it.effect("loads deferred work on demand and resumes a failed page", () => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed", 0, 1), + deferredWork: { cursor: "first", itemCount: 3, lastOrdinal: 3 }, + }; + const requests: string[] = []; + let failed = false; + const harness = yield* makeHarness({ + cached: cachedItem(anchor), + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + expect(url.searchParams.get("view")).toBe("work"); + const cursor = url.searchParams.get("cursor")!; + requests.push(cursor); + if (cursor === "second" && !failed) { + failed = true; + return Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })), + ); + } + return Effect.succeed( + historyPage( + request, + [ + localRow( + command(`work-${cursor}`, "completed", 0, cursor === "first" ? 3 : 2), + 0, + ), + ], + cursor === "first" ? "second" : null, + ), + ); + }), + }); + expect(requests).toEqual([]); + expect((yield* harness.loadWork(anchor.id))._tag).toBe("error"); + const partial = Option.getOrThrow((yield* SubscriptionRef.get(harness.threadState)).data); + expect(partial.visibleTurnItems[0]?.item.deferredWork?.cursor).toBe("second"); + expect(yield* harness.loadWork(anchor.id)).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.map((row) => row.item.ordinal), + ).toEqual([1, 2, 3]); + expect(state.history.historyCursor).toBe("older"); + expect(requests).toEqual(["first", "second", "second"]); + }), + ); + + it.effect.each(["deferred", "complete"] as const)( + "settles a replaced work request when the new snapshot is %s", + (mode) => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed"), + deferredWork: { cursor: "before", itemCount: 80, lastOrdinal: 80 }, + }; + const asked = yield* Deferred.make(); + const release = yield* Deferred.make(); + const harness = yield* makeHarness({ + cached: cachedItem(anchor), + historyHttpClient: HttpClient.make((request) => + Effect.gen(function* () { + yield* Deferred.succeed(asked, undefined); + yield* Deferred.await(release); + return historyPage(request, [], null); + }), + ), + }); + const loading = yield* harness.loadWork(anchor.id).pipe(Effect.forkChild); + yield* Deferred.await(asked); + const replacement = + mode === "deferred" + ? { ...anchor, deferredWork: { ...anchor.deferredWork, cursor: "after" } } + : command("anchor", "completed"); + yield* Queue.offer( + harness.inputs, + snapshot( + { + ...BASE_PROJECTION, + thread: { ...BASE_PROJECTION.thread, title: "Replacement" }, + turnItems: [replacement], + visibleTurnItems: [localRow(replacement, 0)], + }, + CACHED_SNAPSHOT_SEQUENCE + 1, + ), + ); + yield* awaitThreadState( + harness.observed, + (value) => Option.getOrNull(value.data)?.thread.title === "Replacement", + ); + yield* Deferred.succeed(release, undefined); + expect((yield* Fiber.join(loading))._tag).toBe(mode === "deferred" ? "error" : "noop"); + }), + ); + + it.effect("does not mark replacement history loading when a queued request becomes stale", () => + Effect.gen(function* () { + const harness = yield* makeHarness({ + cached: BASE_PROJECTION, + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request) => + Effect.succeed(historyPage(request, [], null)), + ), + }); + const held = yield* Deferred.make(); + const release = yield* Deferred.make(); + const replacement = yield* SubscriptionRef.updateEffect(harness.threadState, (latest) => + Deferred.succeed(held, undefined).pipe( + Effect.andThen(Deferred.await(release)), + Effect.as({ ...latest, history: { ...latest.history, historyCursor: "replacement" } }), + ), + ).pipe(Effect.forkChild({ startImmediately: true })); + yield* Deferred.await(held); + const dispatcher = new Scheduler.MixedScheduler("sync").makeDispatcher(); + const loading = yield* harness.loadEarlier().pipe( + Effect.forkChild({ startImmediately: true }), + Effect.provideService(Scheduler.Scheduler, { + executionMode: "sync", + shouldYield: () => false, + makeDispatcher: () => dispatcher, + }), + ); + dispatcher.flush(); + yield* Deferred.succeed(release, undefined); + yield* Fiber.join(replacement); + dispatcher.flush(); + expect(yield* Fiber.join(loading)).toEqual({ _tag: "noop" }); + expect((yield* SubscriptionRef.get(harness.threadState)).history).toMatchObject({ + historyCursor: "replacement", + loading: false, + }); + }), + ); + + it.effect("loads targeted messages without fetching unrelated deferred work", () => + Effect.gen(function* () { + const anchor = { + ...command("anchor", "completed"), + deferredWork: { cursor: "work", itemCount: 80, lastOrdinal: 80 }, + }; + const message = { + ...command("older-message", "completed"), + type: "assistant_message" as const, + messageId: MessageId.make("older-message"), + text: "Older response", + streaming: false, + }; + const views: Array = []; + const harness = yield* makeHarness({ + cached: cachedItem(anchor), + cachedHistory: { historyCursor: "older", hasMoreHistory: true }, + historyHttpClient: HttpClient.make((request, url) => { + views.push(url.searchParams.get("view")); + return Effect.succeed( + url.searchParams.get("view") === "work" + ? HttpClientResponse.fromWeb(request, new Response("Unavailable", { status: 503 })) + : historyPage(request, [localRow(message, 0)], null), + ); + }), + }); + expect(yield* harness.loadEarlier(message.messageId)).toEqual({ _tag: "loaded" }); + const state = yield* SubscriptionRef.get(harness.threadState); + expect( + Option.getOrThrow(state.data).visibleTurnItems.some( + (row) => row.sourceItemId === message.id, + ), + ).toBe(true); + expect(views).toEqual(["conversation", null]); + }), + ); + // A fork's parent row keeps its own source; the child's Stop must not touch it. const inheritedRow = { position: 0, @@ -1248,6 +1506,17 @@ describe("EnvironmentThreads", () => { text: "COD4 result", streaming: false, }; + const prompt = { + ...message, + id: "item:older-prompt", + messageId: "message:older-prompt", + ordinal: 0, + type: "user_message", + inputIntent: "turn_start", + createdBy: "user", + creationSource: "web", + attachments: [], + }; const activity = { ...common, id: "item:older-command", @@ -1270,6 +1539,16 @@ describe("EnvironmentThreads", () => { const harness = yield* makeHarness({ historyHttpClient: HttpClient.make((request, url) => Effect.gen(function* () { + if (url.searchParams.get("cursor") === "next-cursor") + return HttpClientResponse.fromWeb( + request, + Response.json({ + snapshotSequence: 14, + items: [projected(prompt, 0)], + nextCursor: "before-prompt", + hasMoreHistory: true, + }), + ); expect(url.searchParams.get("cursor")).toBe("progressive-cursor"); const conversationOnly = url.searchParams.get("view") === "conversation"; if (!conversationOnly && activityRequests++ === 0) { @@ -1341,14 +1620,16 @@ describe("EnvironmentThreads", () => { const complete = yield* SubscriptionRef.get(harness.threadState); expect(complete.history).toMatchObject({ loading: false, - historyCursor: "next-cursor", + historyCursor: mode === "retry" ? "before-prompt" : "next-cursor", error: null, latestLocalTurnOrdinal: 47, }); expect( Option.getOrThrow(complete.data).visibleTurnItems.map((row) => row.sourceItemId), - ).toEqual([message.id, activity.id]); - expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(2); + ).toEqual( + mode === "retry" ? [prompt.id, message.id, activity.id] : [message.id, activity.id], + ); + expect(Option.getOrThrow(complete.data).turnItems).toHaveLength(mode === "retry" ? 3 : 2); }), ); diff --git a/packages/client-runtime/src/state/threads.ts b/packages/client-runtime/src/state/threads.ts index f46cea41fc8f6..3cb4192f0ab6c 100644 --- a/packages/client-runtime/src/state/threads.ts +++ b/packages/client-runtime/src/state/threads.ts @@ -658,29 +658,40 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make const loadEarlier = Effect.fn("EnvironmentThreadState.loadEarlier")(function* ( throughEntryId?: string, - ) { + workAnchorId?: string, + ): Effect.fn.Return { + const workCursor = (latest: EnvironmentThreadState) => + Option.getOrNull(latest.data)?.visibleTurnItems.find( + (row) => row.sourceItemId === workAnchorId, + )?.item.deferredWork?.cursor ?? null; const current = yield* SubscriptionRef.get(state); + const initialCursor = + workAnchorId === undefined ? current.history.historyCursor : workCursor(current); if ( current.status === "deleted" || Option.isNone(current.data) || - !current.history.hasMoreHistory || - current.history.historyCursor === null - ) { - return { _tag: "noop" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } - if (current.history.loading) { - return { _tag: "busy" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; - } - - // Capture the cursor that initiated this request. Completions/failures must - // no-op if a socket or new bounded snapshot replaced progressive meta mid-flight. - const requestCursor = current.history.historyCursor; - yield* patchHistoryMeta((history) => - isActiveHistoryRequestCursor(requestCursor, history) - ? { ...history, loading: true, error: null } - : history, - ); - + initialCursor === null || + (workAnchorId === undefined && !current.history.hasMoreHistory) + ) + return { _tag: "noop" }; + if (workAnchorId === undefined && current.history.loading) return { _tag: "busy" }; + let requestCursor = initialCursor; + const isCurrent = (latest: EnvironmentThreadState) => + workAnchorId === undefined + ? isActiveHistoryRequestCursor(requestCursor, latest.history) + : workCursor(latest) === requestCursor; + const staleResult = ( + latest: EnvironmentThreadState, + ): ThreadHistoryController.ThreadHistoryLoadEarlierResult => + workAnchorId !== undefined && workCursor(latest) !== null + ? { _tag: "error", message: "Work history changed. Retry loading it." } + : { _tag: "noop" }; + if (workAnchorId === undefined) + yield* patchHistoryMeta((history) => + isActiveHistoryRequestCursor(requestCursor, history) + ? { ...history, loading: true, error: null } + : history, + ); const failLoad = (message: string) => SubscriptionRef.modify( state, @@ -690,72 +701,108 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ThreadHistoryController.ThreadHistoryLoadEarlierResult, EnvironmentThreadState, ] => - isActiveHistoryRequestCursor(requestCursor, latest.history) + isCurrent(latest) ? [ { _tag: "error", message }, { ...latest, - history: { ...latest.history, loading: false, error: message }, + history: + workAnchorId === undefined + ? { ...latest.history, loading: false, error: message } + : latest.history, }, ] - : [{ _tag: "noop" }, latest], + : [staleResult(latest), latest], ); const runLoad = Effect.gen(function* () { - const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); - if (Option.isNone(preparedOption) || Option.isNone(httpClient)) - return yield* failLoad("Environment is not connected."); - - const views = - throughEntryId === undefined ? [undefined] : ["conversation" as const, undefined]; - for (const view of views) { - const pageResult = yield* fetchEnvironmentThreadHistoryPage({ - prepared: preparedOption.value, - threadId, - cursor: requestCursor, - throughEntryId, - view, - signer: dpopSigner, - remoteAuthorization, - }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); - if (Result.isFailure(pageResult)) - return yield* failLoad(formatHistoryError(pageResult.failure)); - const page = pageResult.success; - const complete = view === undefined; - const waiting = yield* Ref.get(awaitingCompletion); - const result = yield* applyLock.withPermits(1)( - SubscriptionRef.modify( - state, - ( - latest, - ): readonly [ - ThreadHistoryController.ThreadHistoryLoadEarlierResult, - EnvironmentThreadState, - ] => { - if (!isActiveHistoryRequestCursor(requestCursor, latest.history)) - return [{ _tag: "noop" }, latest]; - if (Option.isNone(latest.data) || latest.status === "deleted") - return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; - return [ - { _tag: "loaded" }, - { - ...latest, - data: Option.some(mergeOlderHistoryIntoProjection(latest.data.value, page.items)), - status: complete && waiting ? "synchronizing" : latest.status, - history: complete - ? applyHistoryPageMeta(latest.history, page) - : { ...latest.history, expanded: true }, - }, - ]; - }, - ), - ); - if (result._tag !== "loaded") return result; - if (complete) yield* remember; + while (true) { + const preparedOption = yield* SubscriptionRef.get(supervisor.prepared); + if (Option.isNone(preparedOption) || Option.isNone(httpClient)) + return yield* failLoad("Environment is not connected."); + const views = + workAnchorId !== undefined + ? ["work" as const] + : throughEntryId === undefined + ? [undefined] + : ["conversation" as const, undefined]; + let nextCursor: string | null = null; + for (const view of views) { + const pageResult = yield* fetchEnvironmentThreadHistoryPage({ + prepared: preparedOption.value, + threadId, + cursor: requestCursor, + throughEntryId: workAnchorId ?? throughEntryId, + view, + signer: dpopSigner, + remoteAuthorization, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient.value), Effect.result); + if (Result.isFailure(pageResult)) + return yield* failLoad(formatHistoryError(pageResult.failure)); + const page = pageResult.success; + const complete = view !== "conversation"; + const keepPaging = + complete && + (workAnchorId !== undefined + ? page.nextCursor !== null + : throughEntryId === undefined && + page.hasMoreHistory && + !page.items.some( + ({ item }) => item.type === "user_message" && item.createdBy === "user", + )); + if (keepPaging && (page.nextCursor === null || page.nextCursor === requestCursor)) + return yield* failLoad( + workAnchorId === undefined + ? "History did not advance." + : "Work history did not advance.", + ); + const waiting = yield* Ref.get(awaitingCompletion); + const result = yield* applyLock.withPermits(1)( + SubscriptionRef.modify( + state, + ( + latest, + ): readonly [ + ThreadHistoryController.ThreadHistoryLoadEarlierResult, + EnvironmentThreadState, + ] => { + if (!isCurrent(latest)) return [staleResult(latest), latest]; + if (Option.isNone(latest.data) || latest.status === "deleted") + return [{ _tag: "noop" }, { ...latest, history: EMPTY_THREAD_HISTORY_META }]; + return [ + { _tag: "loaded" }, + { + ...latest, + data: Option.some( + mergeOlderHistoryIntoProjection( + latest.data.value, + page.items, + workAnchorId === undefined + ? undefined + : { anchorId: workAnchorId, nextCursor: page.nextCursor }, + ), + ), + status: + workAnchorId === undefined && complete && waiting + ? "synchronizing" + : latest.status, + history: + workAnchorId === undefined && complete + ? { ...applyHistoryPageMeta(latest.history, page), loading: keepPaging } + : { ...latest.history, expanded: true }, + }, + ]; + }, + ), + ); + if (result._tag !== "loaded") return result; + if (complete) yield* remember; + nextCursor = keepPaging ? page.nextCursor : null; + } + if (nextCursor === null) return { _tag: "loaded" } as const; + requestCursor = nextCursor; } - return { _tag: "loaded" } satisfies ThreadHistoryController.ThreadHistoryLoadEarlierResult; }); - return yield* runLoad.pipe( Effect.onInterrupt(() => SubscriptionRef.update(state, (latest) => ({ @@ -832,8 +879,11 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make if (Option.isSome(historyController)) { const scope = yield* Scope.Scope; const registration = yield* historyController.value.register(environmentId, threadId, { - loadEarlier: (throughEntryId) => - loadEarlier(throughEntryId).pipe(Effect.forkIn(scope), Effect.flatMap(Fiber.join)), + loadEarlier: (throughEntryId, workAnchorId) => + loadEarlier(throughEntryId, workAnchorId).pipe( + Effect.forkIn(scope), + Effect.flatMap(Fiber.join), + ), reconcilePendingWork: () => reconcilePendingWork().pipe( Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), @@ -962,6 +1012,7 @@ export const makeEnvironmentThreadState = Effect.fn("EnvironmentThreadState.make ...(acceptBoundedSnapshot ? { acceptBoundedSnapshot: true as const, + acceptDeferredWork: true as const, acceptCompactTurnItems: true as const, acceptCompactIds: true as const, } diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index ad6780ae71481..30c33deebd55c 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -657,6 +657,7 @@ const EnvironmentOrchestrationShellSnapshotQuery = Schema.Struct({ }); const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ + deferWork: Schema.optionalKey(Schema.String), compactTurnItems: Schema.optionalKey(Schema.String), dedupeBodies: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), @@ -665,7 +666,8 @@ const EnvironmentOrchestrationThreadBoundedSnapshotQuery = Schema.Struct({ const EnvironmentOrchestrationThreadHistoryQuery = Schema.Struct({ cursor: TrimmedNonEmptyString, throughEntryId: Schema.optional(TrimmedNonEmptyString), - view: Schema.optional(Schema.Literals(["conversation", "activity"])), + view: Schema.optional(Schema.Literals(["conversation", "activity", "work"])), + deferWork: Schema.optionalKey(Schema.String), compactIds: Schema.optionalKey(Schema.String), }); diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts index 993fb62453d59..56d910b752deb 100644 --- a/packages/contracts/src/orchestrationV2.ts +++ b/packages/contracts/src/orchestrationV2.ts @@ -1447,6 +1447,14 @@ export type OrchestrationV2UserMessageInputIntent = typeof OrchestrationV2UserMessageInputIntent.Type; const OrchestrationV2TurnItemBaseFields = { + /** Wire-only cursor for collapsed work; persisted items keep their complete history. */ + deferredWork: Schema.optionalKey( + Schema.Struct({ + cursor: TrimmedNonEmptyString, + itemCount: PositiveInt, + lastOrdinal: NonNegativeInt, + }), + ), toolNonExecutionKind: Schema.optional(Schema.String), toolSurface: Schema.optional(ToolActivitySurface), toolIcon: Schema.optional(ToolActivityIcon), @@ -3680,6 +3688,7 @@ export const OrchestrationV2SubscribeThreadInput = Schema.Struct({ afterSequence: Schema.optionalKey(NonNegativeInt), requestCompletionMarker: Schema.optionalKey(Schema.Boolean), acceptBoundedSnapshot: Schema.optionalKey(Schema.Boolean), + acceptDeferredWork: Schema.optionalKey(Schema.Boolean), acceptCompactTurnItems: Schema.optionalKey(Schema.Boolean), /** The client restores `*Omitted` bodies from records it already holds. */ acceptDedupedBodies: Schema.optionalKey(Schema.Boolean), From c3858bf1f70a1a16a3582ff870307c55b358b5c3 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Wed, 28 Oct 2026 04:05:51 -0700 Subject: [PATCH 221/317] fix(web): snooze options sort soonest first, fit 2 digits, and link from custom snooze (#26427) Co-authored-by: Claude Opus 5.5 --- .../web/src/components/CustomSnoozeDialog.tsx | 19 +++++- .../SnoozePresetsSettings.logic.test.ts | 20 +++++- .../settings/SnoozePresetsSettings.logic.ts | 25 +++++++ .../settings/SnoozePresetsSettings.tsx | 68 +++++++++++++++---- 4 files changed, 117 insertions(+), 15 deletions(-) diff --git a/apps/web/src/components/CustomSnoozeDialog.tsx b/apps/web/src/components/CustomSnoozeDialog.tsx index efbc5d90da6b6..43c466184bf2c 100644 --- a/apps/web/src/components/CustomSnoozeDialog.tsx +++ b/apps/web/src/components/CustomSnoozeDialog.tsx @@ -1,4 +1,5 @@ import { useEffect, useId, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; import { create } from "zustand"; import { localSnoozeDate, @@ -7,7 +8,7 @@ import { type CustomSnoozeInput, } from "@t3tools/client-runtime/state/thread-settled"; import { Button } from "./ui/button"; -import { CalendarIcon } from "lucide-react"; +import { CalendarIcon, Settings2Icon } from "lucide-react"; import { Calendar } from "./ui/calendar"; import { weekStartsOn } from "../timestampFormat"; import { useNowMinute } from "../hooks/useNowMinute"; @@ -63,6 +64,7 @@ export function CustomSnoozeDialogHost() { function CustomSnoozeDialog() { const id = useId(); + const navigate = useNavigate(); const [initial] = useState(() => new Date(Date.now() + 3_600_000)); const nowMinute = useNowMinute(); // Follows the clock so a dialog left open past midnight stops offering yesterday. @@ -225,6 +227,21 @@ function CustomSnoozeDialog() { )} + diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts b/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts index e5126ba8bba6d..05d62987fb23e 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts +++ b/apps/web/src/components/settings/SnoozePresetsSettings.logic.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { reconcileSnoozeDraft } from "./SnoozePresetsSettings.logic"; +import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; describe("reconcileSnoozeDraft", () => { it("keeps the draft while our own writes echo back in order", () => { @@ -18,3 +18,21 @@ describe("reconcileSnoozeDraft", () => { expect(reconcileSnoozeDraft(["a", "b", "a"], "b")).toEqual({ adopt: false, pending: ["a"] }); }); }); + +describe("sortSnoozeRows", () => { + it("orders rows by wake time and puts a passed today option last", () => { + // 16:00 local, so 20 hours wakes tomorrow and today 09:00 has passed. + const now = new Date(2026, 9, 28, 16, 0); + const sorted = sortSnoozeRows( + [ + { kind: "after", minutes: 20 * 60 }, + { kind: "at", day: "today", time: "09:00" }, + { kind: "at", day: "today", time: "18:00" }, + { kind: "after", minutes: 60 }, + ], + ["20h", "passed", "evening", "1h"], + now, + ); + expect(sorted.keys).toEqual(["1h", "evening", "20h", "passed"]); + }); +}); diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts b/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts index ece0e2ee5a922..5e778be591b68 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts +++ b/apps/web/src/components/settings/SnoozePresetsSettings.logic.ts @@ -1,3 +1,5 @@ +import type { SnoozePresetSetting } from "@t3tools/contracts/settings"; +import { resolveSnoozePreset } from "@t3tools/client-runtime/state/thread-settled"; import * as Equal from "effect/Equal"; /** @@ -13,3 +15,26 @@ export function reconcileSnoozeDraft( ? { adopt: true } : { adopt: false, pending: pending.slice(acknowledged + 1) }; } + +/** + * Orders editor rows the way the menu lists them: soonest wake first, with options that + * have no wake right now (a passed "Today") last. Ties keep their current order. + */ +export function sortSnoozeRows( + presets: ReadonlyArray, + keys: ReadonlyArray, + now: Date, +): { readonly presets: ReadonlyArray; readonly keys: ReadonlyArray } { + const order = presets + .map((preset, index) => ({ + index, + wake: resolveSnoozePreset(preset, now)?.snoozedUntil ?? null, + })) + .toSorted((a, b) => + a.wake === b.wake ? 0 : a.wake === null ? 1 : b.wake === null ? -1 : a.wake < b.wake ? -1 : 1, + ); + return { + presets: order.map(({ index }) => presets[index]!), + keys: order.map(({ index }) => keys[index]!), + }; +} diff --git a/apps/web/src/components/settings/SnoozePresetsSettings.tsx b/apps/web/src/components/settings/SnoozePresetsSettings.tsx index d7129e76dd208..22f2d43497fde 100644 --- a/apps/web/src/components/settings/SnoozePresetsSettings.tsx +++ b/apps/web/src/components/settings/SnoozePresetsSettings.tsx @@ -16,7 +16,7 @@ import * as Schema from "effect/Schema"; import { PlusIcon, XIcon } from "lucide-react"; import { useNowMinute } from "../../hooks/useNowMinute"; -import { reconcileSnoozeDraft } from "./SnoozePresetsSettings.logic"; +import { reconcileSnoozeDraft, sortSnoozeRows } from "./SnoozePresetsSettings.logic"; import { formatShortTimestamp } from "../../timestampFormat"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; @@ -90,6 +90,16 @@ function nextCandidate(presets: ReadonlyArray): SnoozePrese ); } +// Row order is only display order, so it does not make a list differ from the defaults. +function sameSnoozePresets( + left: ReadonlyArray, + right: ReadonlyArray, +) { + const ids = (presets: ReadonlyArray) => + presets.map(snoozePresetId).toSorted(); + return Equal.equals(ids(left), ids(right)); +} + const isSnoozePresetDay = Schema.is(SnoozePresetDay); interface KindMemory { @@ -109,16 +119,28 @@ function withWhen( return { kind: "at", day: when, time: preset.kind === "at" ? preset.time : memory.time }; } -/** Edits the snooze menu's options. Menus sort them by wake time, so rows keep the order they were added in. */ +/** + * Edits the snooze menu's options. Rows sort by wake time like the menu, but only when focus + * leaves the list, so a row never moves under the pointer mid-edit. + */ export function SnoozePresetsRow() { const settings = useScopedSettings(); const updateSettings = useUpdateScopedSettings(); const saved = settings.snoozePresets; + const nowMinute = useNowMinute(); + const now = new Date(`${nowMinute}Z`); // Edits build on a local draft, so a second edit before the first save echoes back // does not restore what the first one changed. Echoes of our own writes keep the draft. - const [presets, setPresets] = useState(saved); - // Row keys survive removals, so a row's in-progress field state stays with that row. - const [keys, setKeys] = useState>(() => saved.map((_, index) => index)); + // Row keys survive removals and sorts, so a row's in-progress field state stays with it. + // One state, so a sort queued in the same event as an edit applies on top of it. + const [rows, setRows] = useState(() => + sortSnoozeRows( + saved, + saved.map((_, index) => index), + now, + ), + ); + const { presets, keys } = rows; const [nextKey, setNextKey] = useState(saved.length); // Null forces the next render to adopt the saved value, as after a failed write. const [savedSource, setSavedSource] = useState | null>(saved); @@ -134,8 +156,7 @@ export function SnoozePresetsRow() { const reconciled = reconcileSnoozeDraft(written, saved); if (reconciled.adopt) { setWritten([]); - setPresets(saved); - setKeys(freshKeys(saved.length)); + setRows(sortSnoozeRows(saved, freshKeys(saved.length), now)); } else { setWritten(reconciled.pending); } @@ -145,8 +166,7 @@ export function SnoozePresetsRow() { nextKeys: ReadonlyArray = keys, ) => { if (!mixed && Equal.equals(snoozePresets, presets)) return; - setPresets(snoozePresets); - setKeys(nextKeys); + setRows({ presets: snoozePresets, keys: nextKeys }); setWritten((previous) => [...previous, snoozePresets]); updateSettings( { snoozePresets }, @@ -168,10 +188,17 @@ export function SnoozePresetsRow() { {...searchableSetting("snooze-options")} description="Choices in every snooze menu, listed soonest first. Weekdays mean the next one after today." resetAction={ - !mixed && Equal.equals(presets, DEFAULT_SNOOZE_PRESETS) ? null : ( + !mixed && sameSnoozePresets(presets, DEFAULT_SNOOZE_PRESETS) ? null : ( save(DEFAULT_SNOOZE_PRESETS, freshKeys(DEFAULT_SNOOZE_PRESETS.length))} + onClick={() => { + const sorted = sortSnoozeRows( + DEFAULT_SNOOZE_PRESETS, + freshKeys(DEFAULT_SNOOZE_PRESETS.length), + now, + ); + save(sorted.presets, sorted.keys); + }} /> ) } @@ -188,6 +215,7 @@ export function SnoozePresetsRow() { ) } onAdd={() => save([...presets, nextCandidate(presets)], [...keys, ...freshKeys(1)])} + onSettle={() => setRows((current) => sortSnoozeRows(current.presets, current.keys, now))} /> ); @@ -200,6 +228,7 @@ function SnoozePresetList(props: { onChange: (index: number, preset: SnoozePresetSetting) => void; onRemove: (index: number) => void; onAdd: () => void; + onSettle: () => void; }) { const nowMinute = useNowMinute(); const now = new Date(`${nowMinute}Z`); @@ -232,7 +261,20 @@ function SnoozePresetList(props: { ); return ( -
    +
    { + const list = event.currentTarget; + const next = event.relatedTarget; + if (list.contains(next)) return; + // Select popups portal out of the list, so focus moving into one is not leaving it. + if (next instanceof Element && next.closest('[data-slot="select-popup"]')) return; + // Waits for the amount field's blur commit, and skips its Enter blur-and-refocus. + queueMicrotask(() => { + if (!list.contains(document.activeElement)) props.onSettle(); + }); + }} + > {props.presets.map((preset, index) => { const preview = previews[index]!; return ( @@ -371,7 +413,7 @@ function DurationFields(props: { minutes: number; onChange: (minutes: number) => <> Date: Wed, 28 Oct 2026 04:21:58 -0700 Subject: [PATCH 222/317] fix(web): file preview wrap buttons no longer turn off word wrap everywhere (#26432) Co-authored-by: Claude Opus 5.5 --- .../AttachmentFilePreview.browser.test.tsx | 22 ++++++++++++++++++- .../files/AttachmentFilePreview.tsx | 13 ++++++----- .../src/components/files/FilePreviewPanel.tsx | 12 +++++++--- .../src/components/files/LargeTextViewer.tsx | 5 ++++- .../files/ReadOnlySourcePreview.tsx | 5 ++++- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx index 9879edfeedbd8..7492313f3c7e8 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.browser.test.tsx @@ -18,7 +18,9 @@ vi.mock(import("~/assets/assetUrls"), async (original) => ({ // The real source view highlights through a worker pool; the text it receives is what matters here. // Never load the original: its highlighter graph can outlast waitFor on a cold CI browser. vi.mock(import("./ReadOnlySourcePreview"), () => ({ - default: ({ text }: { text: string }) =>
    {text}
    , + default: ({ text, wordWrap }: { text: string; wordWrap?: boolean }) => ( +
    {text}
    + ), })); // The Word engine compiles about 30 MB of WebAssembly in a fresh worker per document, which took @@ -41,6 +43,7 @@ vi.mock(import("./office/WordDocument"), async () => { }; }); +import { getClientSettings } from "~/hooks/useSettings"; import { AttachmentFilePreview } from "./AttachmentFilePreview"; import LargeTextViewer from "./LargeTextViewer"; import VirtualTablePreview from "./VirtualTablePreview"; @@ -492,6 +495,7 @@ describe("large text preview recovery", () => { workspaceMutationId={mutation} revealLine={null} revealRequestId={0} + wordWrap />
    ); @@ -2341,6 +2345,22 @@ it("toggles a Mermaid attachment between diagram and source through the lazy pre expect(screen.container.querySelector('[role="alert"]')).toBeNull(); }); +it("unwraps one text attachment without turning off the word wrap setting", async () => { + const file = new Blob(["a long line"], { type: "text/plain" }); + await using screen = await render( + , + ); + await screen.getByRole("button", { name: "Disable word wrap" }).click(); + await expect.element(screen.getByRole("button", { name: "Enable word wrap" })).toBeVisible(); + expect(getClientSettings().wordWrap).toBe(true); + expect(screen.container.querySelector("pre")?.dataset.wordWrap).toBe("false"); +}); + it("mounts a bounded set of saved notebook outputs as the reader scrolls", async () => { const notebook = { nbformat: 4, diff --git a/apps/web/src/components/files/AttachmentFilePreview.tsx b/apps/web/src/components/files/AttachmentFilePreview.tsx index e57289bd60144..1d8bc609ff56a 100644 --- a/apps/web/src/components/files/AttachmentFilePreview.tsx +++ b/apps/web/src/components/files/AttachmentFilePreview.tsx @@ -21,7 +21,7 @@ import { MorphIcon } from "~/components/MorphIcon"; import { ScrollArea } from "~/components/ui/scroll-area"; import { toastManager } from "~/components/ui/toast"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { cn } from "~/lib/utils"; import { BinaryFilePreview } from "./BinaryFilePreview"; @@ -55,7 +55,7 @@ const SourcePreview = lazy(() => import("./ReadOnlySourcePreview")); const STALE_URL_MS = 5 * 60_000; -export function ReadOnlySourcePreview(props: { name: string; text: string }) { +export function ReadOnlySourcePreview(props: { name: string; text: string; wordWrap?: boolean }) { return ( }> @@ -214,7 +214,9 @@ export function AttachmentFilePreview(props: { return () => controller.abort(); }, [url, needsText, streamsText, revision, props.sizeBytes, props.file, refresh]); const failure = error ?? (needsText ? contentError : null); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const showsRawText = failure === null && needsText && @@ -272,6 +274,7 @@ export function AttachmentFilePreview(props: { onRetry={() => setRevision((value) => value + 1)} delimiter={rendered ? delimiter : null} copyContents={copyContents} + wordWrap={wordWrap} /> ) : !url || (needsText && !content) ? ( @@ -292,7 +295,7 @@ export function AttachmentFilePreview(props: { /> ) : ( - + ) ) : kind === "archive" || kind === "sqlite" ? ( updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 56854b3ec6f72..03e682758e321 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -56,7 +56,7 @@ import { MediaVideoPlayer } from "~/components/media/MediaVideoPlayer"; import { MediaActions, type MediaActionSource } from "~/components/media/MediaActions"; import { MorphIcon } from "~/components/MorphIcon"; import { useRemoteOpenState } from "~/remoteOpen"; -import { useClientSettings, updateClientSettings } from "~/hooks/useSettings"; +import { useClientSettings } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { getLocalStorageItem, setLocalStorageItem, useLocalStorage } from "~/hooks/useLocalStorage"; import { resolveDiffThemeName } from "~/lib/diffRendering"; @@ -996,7 +996,9 @@ export default function FilePreviewPanel({ attachment === undefined ? resolveFilePreviewPath(requestedPath, cwd) : requestedPath; const draft = typeof composerDraftTarget === "string"; const { resolvedTheme } = useTheme(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const [wordWrapOverride, setWordWrapOverride] = useState(null); + const wordWrap = wordWrapOverride ?? defaultWordWrap; const canOperatePreview = useEnvironmentScope(environmentId, AuthPreviewOperateScope); const primaryEnvironmentId = usePrimaryEnvironmentId(); const remoteOpenState = useRemoteOpenState(environmentId); @@ -1291,7 +1293,7 @@ export default function FilePreviewPanel({ updateClientSettings({ wordWrap: !wordWrap })} + onPress={() => setWordWrapOverride(!wordWrap)} > @@ -1403,6 +1405,7 @@ export default function FilePreviewPanel({ workspaceMutationId={workspaceMutationId} revealLine={revealLine} revealRequestId={revealRequestId} + wordWrap={wordWrap} /> ) : relativePath && absolutePath && @@ -1488,6 +1491,7 @@ export default function FilePreviewPanel({ text={file.data.contents} cacheKey={projectFileCacheKey(cwd, relativePath, file.data.contents)} onPostRender={onFilePostRender} + wordWrap={wordWrap} /> ) : ( @@ -1557,6 +1561,7 @@ export function WorkspaceLargeTextPreview(props: { workspaceMutationId: string | null; revealLine: number | null; revealRequestId: number; + wordWrap: boolean; }) { const resource = { _tag: "media-file", @@ -1659,6 +1664,7 @@ export function WorkspaceLargeTextPreview(props: { delimiter={props.delimiter} revealLine={props.revealLine} revealRequestId={props.revealRequestId} + wordWrap={props.wordWrap} /> diff --git a/apps/web/src/components/files/LargeTextViewer.tsx b/apps/web/src/components/files/LargeTextViewer.tsx index a12842f7047c9..097f0dc717f70 100644 --- a/apps/web/src/components/files/LargeTextViewer.tsx +++ b/apps/web/src/components/files/LargeTextViewer.tsx @@ -36,6 +36,8 @@ export default function LargeTextViewer(props: { readonly delimiter: "," | "\t" | null; readonly revealLine?: number | null; readonly revealRequestId?: number; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const [meta, setMeta] = useState | null>(null); const [pages, setPages] = useState(new Map()); @@ -46,7 +48,8 @@ export default function LargeTextViewer(props: { reject: (cause: Error) => void; } | null>(null); const pending = useRef(new Set()); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Own the prefix fetch and indexing worker, cancelling requests and pending copies when the source changes or unmounts. useEffect(() => { // oxlint-disable-next-line react/set-state-in-effect -- A replacement source must discard the previous worker's error and pages. diff --git a/apps/web/src/components/files/ReadOnlySourcePreview.tsx b/apps/web/src/components/files/ReadOnlySourcePreview.tsx index b35143fd36575..2fdfe04a238d6 100644 --- a/apps/web/src/components/files/ReadOnlySourcePreview.tsx +++ b/apps/web/src/components/files/ReadOnlySourcePreview.tsx @@ -13,9 +13,12 @@ export default function ReadOnlySourcePreview(props: { readonly text: string; readonly cacheKey?: string; readonly onPostRender?: FileOptions["onPostRender"]; + /** A preview's own wrap toggle; omitted follows the word wrap setting. */ + readonly wordWrap?: boolean; }) { const resolvedTheme = useRenderedAppearance(); - const wordWrap = useClientSettings((settings) => settings.wordWrap); + const defaultWordWrap = useClientSettings((settings) => settings.wordWrap); + const wordWrap = props.wordWrap ?? defaultWordWrap; return ( Date: Wed, 28 Oct 2026 04:49:42 -0700 Subject: [PATCH 223/317] feat(web): simpler composer with grouped controls, effort words and no collapsed state (#25869) Co-authored-by: Claude Opus 5.5 --- .../settings/DesktopClientSettings.test.ts | 1 - .../web/src/components/BranchToolbar.logic.ts | 6 +- apps/web/src/components/BranchToolbar.tsx | 50 +- .../BranchToolbarBranchSelector.tsx | 3 +- apps/web/src/components/ChatView.tsx | 125 ++--- .../components/ComposerContextPermissions.tsx | 40 ++ apps/web/src/components/chat/ChatComposer.tsx | 465 +++--------------- .../chat/CompactComposerControlsMenu.tsx | 53 +- .../chat/ComposerAttachmentStrip.tsx | 3 +- .../src/components/chat/ComposerControl.tsx | 44 +- .../components/chat/ComposerControlsBar.tsx | 136 +++-- .../chat/ComposerFooterControls.tsx | 204 ++++++-- ...omposerPermissionsSection.browser.test.tsx | 151 ++++++ .../src/components/chat/ComposerSurface.tsx | 35 +- .../src/components/chat/EffortGlyph.test.ts | 67 +++ apps/web/src/components/chat/EffortGlyph.tsx | 86 ++++ .../chat/MessagesTimeline.browser.test.tsx | 23 - .../src/components/chat/MessagesTimeline.tsx | 46 +- .../components/chat/ProviderModelPicker.tsx | 48 +- apps/web/src/components/chat/TraitsPicker.tsx | 140 +++++- .../chat/composerControlsOwnership.test.ts | 90 ++++ .../chat/composerControlsOwnership.ts | 63 +++ .../src/components/chat/composerEventScope.ts | 19 + .../components/chat/composerProviderState.tsx | 13 + .../components/chat/composerScrollGesture.ts | 79 --- .../src/components/chat/runtimeModeConfig.ts | 17 +- .../chat/timelineScrollTarget.test.ts | 24 - .../components/chat/useComposerFocusState.ts | 18 - .../chat/useComposerRestingTransition.ts | 81 +-- .../src/components/chat/useTimelineFollow.ts | 35 -- .../components/composerFooterLayout.test.ts | 25 - .../src/components/composerFooterLayout.ts | 52 -- apps/web/src/components/guest/GuestPage.tsx | 4 - .../settings/AppearanceSettingsPanel.tsx | 39 ++ .../components/settings/SettingsPanels.tsx | 34 +- .../src/components/settings/settingsSearch.ts | 12 +- docs/user/keybindings.md | 2 +- docs/user/permission-modes.md | 4 +- packages/client-runtime/src/modelTraits.ts | 13 +- packages/contracts/src/settings.ts | 8 +- 40 files changed, 1162 insertions(+), 1196 deletions(-) create mode 100644 apps/web/src/components/ComposerContextPermissions.tsx create mode 100644 apps/web/src/components/chat/ComposerPermissionsSection.browser.test.tsx create mode 100644 apps/web/src/components/chat/EffortGlyph.test.ts create mode 100644 apps/web/src/components/chat/EffortGlyph.tsx create mode 100644 apps/web/src/components/chat/composerControlsOwnership.test.ts create mode 100644 apps/web/src/components/chat/composerControlsOwnership.ts delete mode 100644 apps/web/src/components/chat/composerScrollGesture.ts delete mode 100644 apps/web/src/components/chat/useComposerFocusState.ts diff --git a/apps/desktop/src/settings/DesktopClientSettings.test.ts b/apps/desktop/src/settings/DesktopClientSettings.test.ts index ac4c6e61378e3..1a927643be628 100644 --- a/apps/desktop/src/settings/DesktopClientSettings.test.ts +++ b/apps/desktop/src/settings/DesktopClientSettings.test.ts @@ -34,7 +34,6 @@ const clientSettings: ClientSettings = { confirmThreadDelete: false, confirmThreadUnpin: false, contextWindowMeterEnabled: false, - composerCollapseOnScroll: true, diffFilesCollapsed: true, diffIgnoreWhitespace: true, diffLayout: "stacked", diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 80cd7e981375d..e22dcfaddedeb 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -66,12 +66,12 @@ export function shouldShowComposerContextStrip(input: { hasActiveProject: boolean; isGitRepo: boolean; showEnvironmentIndicator: boolean; - hostsRestingComposerControls: boolean; }): boolean { + // A new thread always shows the strip, since it names the permissions the first send uses. return ( input.hasActiveProject && - (input.isDraftHeroState || input.persistInActiveThreads) && - (input.isGitRepo || input.showEnvironmentIndicator || input.hostsRestingComposerControls) + (input.isDraftHeroState || + (input.persistInActiveThreads && (input.isGitRepo || input.showEnvironmentIndicator))) ); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 80edf40d79504..4d57bc7332525 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -5,17 +5,11 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; -import { - ChevronDownIcon, - FolderGit2Icon, - FolderGitIcon, - FolderIcon, - PlusIcon, - ScaleIcon, -} from "lucide-react"; +import { FolderGit2Icon, FolderGitIcon, FolderIcon, PlusIcon, ScaleIcon } from "lucide-react"; import { type Ref, type MouseEvent as ReactMouseEvent, + type ReactNode, memo, useImperativeHandle, useRef, @@ -72,8 +66,6 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { MiddleTruncate } from "./ui/middle-truncate"; import { ComposerSurface } from "./chat/ComposerSurface"; import { useComposerMenuProps } from "./chat/composerEventScope"; -import { measureRestingComposerControls } from "./chat/restingComposerControlsMeasurement"; -import { resolveRestingComposerControlsNaturalWidth } from "./composerFooterLayout"; import { cn } from "~/lib/utils"; import { observeResize } from "~/lib/observeResize"; @@ -105,10 +97,10 @@ interface BranchToolbarProps { envLocked: boolean; onCheckoutPullRequestRequest?: (reference: string) => void; onComposerFocusRequest?: () => void; + /** Shown at the end of the composer strip, such as the new thread's permissions. */ + trailing?: ReactNode; availableEnvironments?: readonly EnvironmentOption[]; onEnvironmentChange?: (environmentId: EnvironmentId) => void; - composerControlsHostRef?: (element: HTMLDivElement | null) => void; - contextStripVisible?: boolean; } function showWorkspaceContextMenu(event: ReactMouseEvent, workspacePath: string) { @@ -317,7 +309,6 @@ const RunContextSelector = memo(function RunContextSelector({ ].join(" ")} > {triggerContent} - {!isPanel ? : null} ('[data-chat-composer-resting-controls="true"]') - : null; - const hostedMeasurement = hostedControls - ? measureRestingComposerControls(hostedControls) - : null; - const width = hostedMeasurement - ? resolveRestingComposerControlsNaturalWidth(hostedMeasurement) - : contentWidth(hostedControls ?? child); + const width = contentWidth(child); if (width <= 1) continue; groups += 1; needed += width; @@ -585,7 +567,6 @@ function useLabelsOverflow(): readonly [ [ current, ...Array.from(current.children), - ...Array.from(current.querySelectorAll("[data-chat-composer-resting-controls='true']")), ...Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)), ], measure, @@ -640,8 +621,7 @@ export const BranchToolbar = memo(function BranchToolbar({ onComposerFocusRequest, availableEnvironments, onEnvironmentChange, - composerControlsHostRef, - contextStripVisible = true, + trailing, }: BranchToolbarProps) { const branchSelectorRef = useRef(null); const threadRef = scopeThreadRef(environmentId, threadId); @@ -764,11 +744,7 @@ export const BranchToolbar = memo(function BranchToolbar({ {showGitControls ? (
    @@ -799,7 +775,7 @@ export const BranchToolbar = memo(function BranchToolbar({ className={cn( "min-h-7 min-w-10 items-center gap-1 sm:min-h-6", showGitControls ? "hidden @3xl/composer-surface:flex" : "flex", - composerControlsHostRef ? "shrink" : "flex-1", + "flex-1", )} > {showEnvironmentIndicator && availableEnvironments && ( @@ -837,15 +813,6 @@ export const BranchToolbar = memo(function BranchToolbar({
    ) : null} - {composerControlsHostRef ? ( -
    - ) : null} - {showGitControls ? ( ) : null} + {trailing} ); }); diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index ef842f21c44c0..95848c209d8c5 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -16,7 +16,7 @@ import { type VcsRef, type ThreadId, } from "@t3tools/contracts"; -import { ChevronDownIcon, GitBranchIcon } from "lucide-react"; +import { GitBranchIcon } from "lucide-react"; import { useDeferredValue, useEffect, @@ -732,7 +732,6 @@ export function BranchToolbarBranchSelector({ className="w-full" /> - {displayMode !== "panel" ? : null} {displayMode === "panel" && prNumber !== undefined && prUrl !== undefined ? ( diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 6af016c18ce91..253cc1ea2e84b 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -175,6 +175,8 @@ import { LinkPullRequestDialogHost } from "./pullRequest/LinkPullRequestDialog"; import { GuestPendingMessages } from "./chat/GuestShareControls"; import { useDeviceState } from "~/state/device"; import { BranchToolbar, type BranchToolbarHandle } from "./BranchToolbar"; +import { ComposerContextPermissions } from "./ComposerContextPermissions"; +import { resolveCompatibleRuntimeMode } from "./chat/runtimeModeConfig"; import { makeWorkspaceFileDropHandlers } from "./chat/workspaceFileDrop"; import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; @@ -267,11 +269,7 @@ import type { MessagesTimelineHistoryControls } from "./chat/MessagesTimeline"; import { ThreadTimeline } from "./chat/ThreadTimeline"; import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; -import { - overlayComposerIsResting, - resolveComposerTimelineInset, - resolveScrollToEndClearance, -} from "./composerFooterLayout"; +import { resolveScrollToEndClearance } from "./composerFooterLayout"; import { ChatHeader } from "./chat/ChatHeader"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; @@ -611,9 +609,6 @@ export default function ChatView(props: ChatViewProps) { const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); const branchToolbarRef = useRef(null); - const [restingComposerControlsHost, setRestingComposerControlsHost] = - useState(null); - const [restingComposerControlsVisible, setRestingComposerControlsVisible] = useState(false); const citeAssistantText = ( citation: AssistantCitation, sourceAnchor: AssistantCitationSourceAnchor, @@ -704,8 +699,6 @@ export default function ChatView(props: ChatViewProps) { setPendingServerThreadStartFromOriginByThreadId, ] = useState>({}); const legendListRef = useRef(null); - const getTimelineScrollableNode = () => legendListRef.current?.getScrollableNode() ?? null; - const getTimelineScrollableNodeStable = useStableCallback(getTimelineScrollableNode); const conversationArea = workspaceAreaProps("conversation", 1, "Conversation"); const composerArea = workspaceAreaProps("composer", 2, "Message input"); const [composerOverlayElement, setComposerOverlayElement] = useState(null); @@ -714,9 +707,7 @@ export default function ChatView(props: ChatViewProps) { const [composerReservedHeight, setComposerReservedHeight] = useState(0); const composerReservedHeightRef = useRef(0); const composerExpandedHeightRef = useRef(0); - const composerRestingRef = useRef(false); const [scrollToEndClearance, setScrollToEndClearance] = useState(0); - const [timelineOverflows, setTimelineOverflows] = useState(false); const fanoutSelectionsAtom = draftFanoutSelectionsAtom(routeThreadKey); const { sendInFlight: sendInFlightRef } = draftSendState(routeThreadKey); const multipleModelSelections = useAtomValue(fanoutSelectionsAtom); @@ -1686,25 +1677,13 @@ export default function ChatView(props: ChatViewProps) { providerSubagentModels, reportedModelSelection, ); - const mountComposerContextStrip = shouldShowComposerContextStrip({ - isDraftHeroState, - persistInActiveThreads: settings.persistComposerContextStrip, - hasActiveProject: activeProject !== null && !showProviderSubagentBar, - isGitRepo, - showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server", - }); const showComposerContextStrip = shouldShowComposerContextStrip({ isDraftHeroState, persistInActiveThreads: settings.persistComposerContextStrip, hasActiveProject: activeProject !== null && !showProviderSubagentBar, isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, - hostsRestingComposerControls: routeKind === "server" && restingComposerControlsVisible, }); - const mountComposerModelStrip = - routeKind === "server" && !mountComposerContextStrip && !showProviderSubagentBar; - const showComposerModelStrip = mountComposerModelStrip && restingComposerControlsVisible; const terminalShortcutLabelOptions = { context: { terminalFocus: true, @@ -1792,6 +1771,9 @@ export default function ChatView(props: ChatViewProps) { }); }; const scheduleComposerFocusStable = useStableCallback(scheduleComposerFocus); + const openComposerPermissionsStable = useStableCallback(() => + composerRef.current?.openControl("composer.mode"), + ); const insertArtifactTemplate = (template: CodexArtifactTemplate) => { const composer = composerRef.current; if (!composer) return; @@ -2047,14 +2029,12 @@ export default function ChatView(props: ChatViewProps) { const { showScrollToBottom, timelineLiveFollowEnabled, - isTimelineAtLogicalEndStable, onComposerPageScrollKeyDownStable, onComposerPageScrollKeyUpStable, onComposerPageScrollReleaseStable, scrollToEnd, onTimelineAnchorReadyStable, onTimelineAnchorSizeChangedStable, - onToolOutputCollapsedAtEndStable, onTimelineFooterGrowStable, onIsAtEndChangeStable, anchorNewTurn, @@ -2062,7 +2042,6 @@ export default function ChatView(props: ChatViewProps) { attachComposerOverlay: attachFollowComposerOverlay, } = useTimelineFollow({ legendListRef, - composerRef, composerTimelineInset, routeThreadKey, activeThreadKey, @@ -2177,29 +2156,9 @@ export default function ChatView(props: ChatViewProps) { const publishComposerOverlayHeight = (height: number) => { const nextHeight = Math.ceil(height); if (nextHeight <= 0) return; - const isResting = overlayComposerIsResting({ - composerMounted, - composerReportedResting: composerRestingRef.current, - }); - const modelStrip = composerOverlayElement?.querySelector( - '[data-composer-model-strip="true"]', - ); - const restingOnlyHeight = - modelStrip && isResting - ? Math.max( - 0, - modelStrip.offsetHeight + Number.parseFloat(getComputedStyle(modelStrip).marginTop), - ) - : 0; - const nextInset = resolveComposerTimelineInset({ - currentInset: composerTimelineInsetRef.current, - overlayHeight: height, - isResting, - restingOnlyHeight, - }); - if (composerTimelineInsetRef.current !== nextInset) { - composerTimelineInsetRef.current = nextInset; - setComposerTimelineInset(nextInset); + if (composerTimelineInsetRef.current !== nextHeight) { + composerTimelineInsetRef.current = nextHeight; + setComposerTimelineInset(nextHeight); } // Reserve the expanded composer height for the terminal drawer and empty placeholder. // Keep it while the mobile strip collapses, but allow growth from approvals or tasks. @@ -2208,7 +2167,7 @@ export default function ChatView(props: ChatViewProps) { '[data-chat-composer-stack="true"]', ); const expandedComposerHeight = - isDraftHeroState && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextInset; + isDraftHeroState && stack ? Math.ceil(stack.getBoundingClientRect().height) : nextHeight; const mobileCollapsed = Boolean( stack?.querySelector('[data-chat-composer-mobile-collapsed="true"]'), ); @@ -2240,11 +2199,6 @@ export default function ChatView(props: ChatViewProps) { setScrollToEndClearance(clearance); }; const publishComposerOverlayHeightStable = useStableCallback(publishComposerOverlayHeight); - // The composer's layout effect publishes this flag before parent layout effects or resize observations. - const onComposerRestingChange = (resting: boolean) => { - composerRestingRef.current = resting; - }; - const onComposerRestingChangeStable = useStableCallback(onComposerRestingChange); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the composer overlay element before paint for a new thread or composer mount useLayoutEffect(() => { if (!composerOverlayElement) return; @@ -3333,8 +3287,6 @@ export default function ChatView(props: ChatViewProps) { contentInsetEndAdjustment={composerTimelineInset} liveFollowEnabled={timelineLiveFollowEnabled} onIsAtEndChange={onIsAtEndChangeStable} - onContentOverflowChange={setTimelineOverflows} - onToolOutputCollapsedAtEnd={onToolOutputCollapsedAtEndStable} onViewportAttach={attachTimelineViewport} onFooterGrow={onTimelineFooterGrowStable} hideEmptyPlaceholder={isDraftHeroState || threadDetailLoading} @@ -3349,10 +3301,7 @@ export default function ChatView(props: ChatViewProps) { { - composerRef.current?.restoreAfterTimelineReachedEnd(); - scrollToEnd(true); - }} + onScrollToEnd={() => scrollToEnd(true)} /> )}
    @@ -3403,9 +3352,7 @@ export default function ChatView(props: ChatViewProps) { : undefined } > - + - {mountComposerModelStrip ? ( - -
    - - ) : null} - {mountComposerContextStrip && ( + {showComposerContextStrip && (
    + ), + })} />
    )} diff --git a/apps/web/src/components/ComposerContextPermissions.tsx b/apps/web/src/components/ComposerContextPermissions.tsx new file mode 100644 index 0000000000000..8ee7766edc565 --- /dev/null +++ b/apps/web/src/components/ComposerContextPermissions.tsx @@ -0,0 +1,40 @@ +import type { RuntimeMode } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; + +import { shortcutLabelForCommand } from "../keybindings"; +import { primaryServerKeybindingsAtom } from "../state/server"; +import { ComposerControl } from "./chat/ComposerControl"; +import { runtimeModeConfig } from "./chat/runtimeModeConfig"; +import { ComposerContextLabel } from "./ComposerContextLabel"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; + +/** The new-thread strip names the permissions the first send will use; clicking opens them. */ +export function ComposerContextPermissions(props: { + runtimeMode: RuntimeMode; + onOpen: () => void; +}) { + const shortcutLabel = shortcutLabelForCommand( + useAtomValue(primaryServerKeybindingsAtom), + "composer.mode", + ); + const { icon: Icon, label } = runtimeModeConfig[props.runtimeMode]; + return ( + + + } + > + + Permissions{shortcutLabel ? ` · ${shortcutLabel}` : null} + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 57a3a8aa41909..57838e598161e 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -1,7 +1,11 @@ import { useComposerTypingGuard } from "./useComposerTypingGuard"; import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferences"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; -import { runtimeModeConfig, runtimeModeOptions as runtimeModes } from "./runtimeModeConfig"; +import { + resolveCompatibleRuntimeMode, + runtimeModeConfig, + runtimeModeOptions as runtimeModes, +} from "./runtimeModeConfig"; import { isLocalEnvironmentDisabled } from "../../localEnvironment"; import { usePrimaryEnvironmentId } from "../../state/environments"; import { useRightPanelStore } from "~/rightPanelStore"; @@ -56,7 +60,7 @@ import { useRef, useState, } from "react"; -import { createPortal, flushSync } from "react-dom"; +import { flushSync } from "react-dom"; import { clampCollapsedComposerCursor, type ComposerTrigger, @@ -86,6 +90,7 @@ import { import { isInsideCollapsedComposerControls, isInsideRestingComposerControlScope, + markOpenToPermissions, } from "./composerEventScope"; import { type ComposerFileAttachment, @@ -102,7 +107,6 @@ import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; -import { useComposerFocusState } from "./useComposerFocusState"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { reconcileAttachmentContextReferences, @@ -140,7 +144,6 @@ import { import { shouldUseCompactComposerPrimaryActions, shouldUseCompactComposerFooter, - shouldUseRestingComposerLayout, } from "../composerFooterLayout"; import { type ComposerCitationCommentRequest, @@ -221,20 +224,9 @@ import { import { ComposerPromptLengthValidation } from "./ComposerPromptLengthValidation"; import { pendingDraftWork } from "./pendingDraftWork"; import { importComposerThreadAttachment } from "./composerThreadImport"; -import { isTimelineScrollTarget } from "./timelineScrollTarget"; -import { - createComposerScrollGestureState, - recordComposerScrollGestureEvent, - shouldCollapseComposerForScrollKey, - resetComposerScrollGesture, - suppressActiveComposerScrollGesture, -} from "./composerScrollGesture"; import { observeResize } from "~/lib/observeResize"; const isEnvironmentRequestInvalidError = Schema.is(EnvironmentRequestInvalidError); - -const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; -const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; import { toastManager } from "../ui/toast"; import { PlayIcon } from "lucide-react"; import { proposedPlanTitle } from "@t3tools/shared/proposedPlanText"; @@ -297,8 +289,6 @@ const runtimeModeOptions = runtimeModes.map((mode) => ({ mode, ...runtimeModeCon export interface ChatComposerHandle { focusAtEnd: () => void; focusAt: (cursor: number) => void; - restoreAfterTimelineReachedEnd: () => void; - collapseForTimelineScrollKey: (key: string) => void; addDroppedFiles: (files: File[]) => void; addDroppedFolders: (folders: File[]) => void; hasPendingAttachments: () => boolean; @@ -424,6 +414,7 @@ export interface ChatComposerProps { threadSyncPhase: ThreadSyncPhase | null; runtimeMode: RuntimeMode; + defaultRuntimeMode?: RuntimeMode; interactionMode: ProviderInteractionMode; lockedProvider: ProviderDriverKind | null; @@ -447,14 +438,9 @@ export interface ChatComposerProps { pullRequestProjectId: ProjectId | null; pullRequestRepository: string | null; threadPullRequests: ReadonlyArray | undefined; - restingControlsHost: HTMLDivElement | null; - restingControlsHaveLeadingContext: boolean; - onRestingControlsVisibilityChange: (visible: boolean) => void; - getTimelineScrollableNode: () => HTMLElement | null; - isTimelineAtLogicalEnd: () => boolean; - timelineOverflows: boolean; onComposerOverlayHeightChange: (height: number) => void; - onRestingChange: (resting: boolean) => void; + /** The new-thread strip names the permissions, so the controls row hides its chip. */ + permissionsShownInStrip: boolean; promptRef: React.RefObject; composerImagesRef: React.RefObject; @@ -575,14 +561,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) pullRequestProjectId, pullRequestRepository, threadPullRequests, - restingControlsHost, - restingControlsHaveLeadingContext, - onRestingControlsVisibilityChange, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - timelineOverflows, onComposerOverlayHeightChange, - onRestingChange, promptRef, composerRef, composerImagesRef, @@ -725,7 +704,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const { pendingSnapShotAnimations, pendingSnapShotIds, - pendingSnapShotIdSet, uncommittedSnapShotIds, standaloneComposerImages, } = useComposerImageGroups(attachmentDraftTarget, composerImages, composerPreviewAnnotations); @@ -923,16 +901,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : undefined; const resolvedCompactDisabledReason = compactDisabledReason ?? (noProviderAvailable ? "Compacting is unavailable right now" : null); - const supportedRuntimeModes = selectedProviderEntry?.snapshot.supportedRuntimeModes; - const compatibleRuntimeModeOptions = - supportedRuntimeModes && supportedRuntimeModes.length > 0 - ? runtimeModeOptions.filter((option) => supportedRuntimeModes.includes(option.mode)) - : runtimeModeOptions; - const compatibleRuntimeMode = compatibleRuntimeModeOptions.some( - (option) => option.mode === runtimeMode, - ) - ? runtimeMode - : (compatibleRuntimeModeOptions[0]?.mode ?? runtimeMode); + const { modes: compatibleRuntimeModes, mode: compatibleRuntimeMode } = + resolveCompatibleRuntimeMode( + runtimeMode, + selectedProviderEntry?.snapshot.supportedRuntimeModes, + ); + const compatibleRuntimeModeOptions = runtimeModeOptions.filter((option) => + compatibleRuntimeModes.includes(option.mode), + ); const providerSendBlockReason = getAntigravitySendBlockReason( selectedProviderEntry?.snapshot, @@ -1029,18 +1005,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) null, ); const [isDragOverComposer, setIsDragOverComposer] = useState(false); - const [isComposerFooterCompact, setIsComposerFooterCompact] = useState(false); const [isComposerPrimaryActionsCompact, setIsComposerPrimaryActionsCompact] = useState(false); const [isComposerModelPickerOpen, setIsComposerModelPickerOpen] = useState(false); const isMobileViewport = useMediaQuery("max-sm"); const isSoftwareKeyboardOpen = useSoftwareKeyboardOpen(); - const { - isComposerFocused, - setIsComposerFocused, - isComposerScrollCollapsed, - setIsComposerScrollCollapsed, - restoreAfterTimelineReachedEnd, - } = useComposerFocusState(); + const [isComposerFocused, setIsComposerFocused] = useState(false); const [composerSubmissionError, setComposerSubmissionError] = useState(null); const [providerInputSubmissionError, setProviderInputSubmissionError] = useState( null, @@ -1070,10 +1039,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const mobileComposerExpandFrameRef = useRef(null); const mobileComposerExpandReleaseFrameRef = useRef(null); const mobileComposerExpandInFlightRef = useRef(false); - const composerScrollCollapseTimeoutRef = useRef(null); - const composerScrollCollapseEligibleRef = useRef(false); - const windowRefocusInFlightRef = useRef(false); - const composerScrollGestureRef = useRef(createComposerScrollGestureState()); const pendingThreadImportsRef = useRef>(new Map()); const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed checkpoint revert flag to a ref read by async handlers @@ -1183,8 +1148,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeComposerMenuItemRef.current = activeComposerMenuItem; }); - const nonPersistedComposerImageIdSet = new Set(nonPersistedComposerImageIds); - const isComposerApprovalState = activePendingApproval !== null; // A send declines the approvals shown here to redirect the agent, so it steers the waiting // turn instead of queueing behind it. Approvals the typing guard holds stay unseen and pending. @@ -1236,7 +1199,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) hiddenBlockCount: restingControlsHiddenBlockCount, iconOnlyBlockCount: restingControlsIconOnlyBlockCount, controlsVisible: restingControlsVisible, - } = useRestingComposerControlsLayout(restingControlsHost ?? inlineRestingControlsHost); + } = useRestingComposerControlsLayout(inlineRestingControlsHost); const { attachControls: attachExpandedComposerControls, hiddenBlockCount: expandedControlsHiddenBlockCount, @@ -1524,43 +1487,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) setComposerCursor(cursor); resetComposerTrigger(trigger); setIsDragOverComposer(false); - setIsComposerScrollCollapsed(false); } // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes composer form resizes to switch the footer and primary actions between compact layouts useLayoutEffect(() => { const composerForm = composerFormRef.current; if (!composerForm) return; - const measureComposerFormWidth = () => composerForm.clientWidth; - const measureFooterCompactness = () => { - const composerFormWidth = measureComposerFormWidth(); - const footerCompact = shouldUseCompactComposerFooter(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - const primaryActionsCompact = - footerCompact && - shouldUseCompactComposerPrimaryActions(composerFormWidth, { - hasWideActions: composerFooterHasWideActions, - }); - return { - primaryActionsCompact, - footerCompact, - }; + const measurePrimaryActionsCompact = () => { + const composerFormWidth = composerForm.clientWidth; + const options = { hasWideActions: composerFooterHasWideActions }; + return ( + shouldUseCompactComposerFooter(composerFormWidth, options) && + shouldUseCompactComposerPrimaryActions(composerFormWidth, options) + ); }; - const initialCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact(initialCompactness.primaryActionsCompact); - setIsComposerFooterCompact(initialCompactness.footerCompact); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); return observeResize(composerForm, () => { - const nextCompactness = measureFooterCompactness(); - setIsComposerPrimaryActionsCompact((previous) => - previous === nextCompactness.primaryActionsCompact - ? previous - : nextCompactness.primaryActionsCompact, - ); - setIsComposerFooterCompact((previous) => - previous === nextCompactness.footerCompact ? previous : nextCompactness.footerCompact, - ); + setIsComposerPrimaryActionsCompact(measurePrimaryActionsCompact()); }); }, [composerFooterHasWideActions]); @@ -1631,16 +1575,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) syncComposerDraftPersistedAttachments, ]); - const expandComposerForEditorChange = () => { - suppressActiveComposerScrollGesture( - composerScrollGestureRef.current, - window.performance.now(), - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - setIsComposerScrollCollapsed(false); - }; - const expandComposerForEditorChangeStable = useStableCallback(expandComposerForEditorChange); - const retainedContextPayloads = useRetainedContextPayloads(composerDraftTargetKey); const onPromptChange = ( @@ -1650,7 +1584,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cursorAdjacentToMention: boolean, contextIds: string[], ) => { - expandComposerForEditorChange(); refreshWorkspaceSnapshotOnTyping(); if (activePendingProgress?.activeQuestion && pendingUserInputs.length > 0) { if (activePendingProgress.activeQuestion.allowCustomAnswer === false) return; @@ -2319,72 +2252,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) expandMobileComposer: expandMobileComposerStable, }); const hasBlockingComposerTopDrawer = pendingUserInputs.length > 0; - const hasImageAttachmentAttention = standaloneComposerImages.some((image) => { - const upload = uploadsByImageId[image.id]; - const failedInCurrentEnvironment = - supportsAttachmentUploads && - upload?.status === "failed" && - upload.environmentId === environmentId; - return nonPersistedComposerImageIdSet.has(image.id) && !failedInCurrentEnvironment; - }); - const composerHasExpandedChrome = - showComposerTopDrawer || - composerMenuOpen || - isStashMenuOpen || - isDragOverComposer || - isPreparingWorktree || - showProviderUnavailable || - projectSelectionRequired || - environmentUnavailable !== null || - composerSubmissionError !== null || - providerInputSubmissionError !== null || - hasImageAttachmentAttention || - preparingComposerImages.length > 0; - const isComposerResting = shouldUseRestingComposerLayout({ - isExistingThread: routeKind === "server" && activeThreadId !== null, - isMobileViewport, - isScrollCollapsed: isComposerScrollCollapsed, - hasExpandedChrome: composerHasExpandedChrome, - hasMultilinePrompt, - timelineOverflows, - }); - const expandedComposerImages = isComposerResting - ? standaloneComposerImages.filter((image) => pendingSnapShotIdSet.has(image.id)) - : standaloneComposerImages; - const composerControlsCollapsed = isComposerResting || isComposerCollapsedMobile; - const showInlineRestingControls = composerControlsCollapsed && restingControlsHost === null; - const composerControlsVisibleInStrip = - composerControlsCollapsed && restingControlsHost !== null && restingControlsVisible; - const composerControlsHidden = composerControlsCollapsed && !restingControlsVisible; + const composerControlsCollapsed = isComposerCollapsedMobile; + // The collapsed mobile web composer keeps a second controls row inside the card. + const showInlineRestingControls = isComposerCollapsedMobile; + const composerControlsHidden = showInlineRestingControls && !restingControlsVisible; if (composerControlsHidden && isComposerModelPickerOpen) { setIsComposerModelPickerOpen(false); } - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports whether resting controls show in the strip to ChatView before paint - useLayoutEffect(() => { - onRestingControlsVisibilityChange(composerControlsVisibleInStrip); - }, [composerControlsVisibleInStrip, onRestingControlsVisibilityChange]); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reports the resting state to ChatView's ref before paint - useLayoutEffect(() => { - onRestingChange(isComposerResting); - }, [isComposerResting, onRestingChange]); - const [restingActionsElement, setRestingActionsElement] = useState(null); - const [restingActionsWidth, setRestingActionsWidth] = useState(null); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes resizes of the resting actions DOM node to measure its width - useLayoutEffect(() => { - if (!isComposerResting || !restingActionsElement) return; - const measure = () => setRestingActionsWidth(restingActionsElement.offsetWidth); - measure(); - return observeResize(restingActionsElement, measure); - }, [isComposerResting, restingActionsElement]); - const showMoreRestingImages = () => { - if (isComposerCollapsedMobile) { - expandMobileComposer(); - return; - } - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - scheduleComposerFocus(); - }; const collapsedComposerImagePreviews = standaloneComposerImages.length > 0 ? ( ) : null; const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = usePanelAnimationSettings(); const composerMainSurfaceRef = useComposerRestingTransition( composerControlsCollapsed, - isComposerResting, restingComposerControlsRef, onComposerOverlayHeightChange, panelAnimationsActive, panelAnimationDurationMs, ); - const canTrackComposerScrollGesture = - routeKind === "server" && activeThreadId !== null && !isMobileViewport; - const canScrollCollapseComposer = - canTrackComposerScrollGesture && - settings.composerCollapseOnScroll && - !hasMultilinePrompt && - !composerHasExpandedChrome; - // Clear rather than mask: once eligibility drops, the composer stays expanded until the next - // scroll gesture even if it becomes eligible again. - if (!canScrollCollapseComposer && isComposerScrollCollapsed) { - setIsComposerScrollCollapsed(false); - } - const composerScrollCollapseEligible = canScrollCollapseComposer && !isComposerResting; - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed scroll-collapse eligibility to a ref read by the wheel listener - useLayoutEffect(() => { - composerScrollCollapseEligibleRef.current = composerScrollCollapseEligible; - }); - - // Window activation re-fires focus; ignore that frame to keep the composer collapsed. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener and animation frame that ignore activation focus while the composer is collapsed - useEffect(() => { - if (!isComposerScrollCollapsed) return; - let frame: number | null = null; - const onWindowFocus = () => { - windowRefocusInFlightRef.current = true; - if (frame !== null) window.cancelAnimationFrame(frame); - frame = window.requestAnimationFrame(() => { - frame = null; - windowRefocusInFlightRef.current = false; - }); - }; - window.addEventListener("focus", onWindowFocus); - return () => { - window.removeEventListener("focus", onWindowFocus); - if (frame !== null) window.cancelAnimationFrame(frame); - windowRefocusInFlightRef.current = false; - }; - }, [isComposerScrollCollapsed]); - - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase document wheel listener that collapses the composer on timeline scroll - useEffect(() => { - if (!canTrackComposerScrollGesture) return; - - const finishScrollGesture = () => { - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = null; - resetComposerScrollGesture(composerScrollGestureRef.current); - }; - const handleTimelineWheel = (event: WheelEvent) => { - if (event.ctrlKey || !(event.target instanceof Element)) { - return; - } - - const scrollNode = getTimelineScrollableNode(); - if (!scrollNode) return; - const targetsTimeline = isTimelineScrollTarget(event.target, scrollNode, event.deltaY); - if ( - !scrollNode.contains(event.target) && - !composerScrollGestureRef.current.collapseSuppressed - ) - return; - - if (composerScrollCollapseTimeoutRef.current !== null) { - window.clearTimeout(composerScrollCollapseTimeoutRef.current); - } - composerScrollCollapseTimeoutRef.current = window.setTimeout( - finishScrollGesture, - COMPOSER_SCROLL_GESTURE_RESET_MS, - ); - - const canScrollInGestureDirection = - targetsTimeline && - (event.deltaY < 0 - ? scrollNode.scrollTop > 0 - : scrollNode.scrollTop < scrollNode.scrollHeight - scrollNode.clientHeight); - const deltaPx = - Math.abs(event.deltaY) * - (event.deltaMode === WheelEvent.DOM_DELTA_LINE - ? 16 - : event.deltaMode === WheelEvent.DOM_DELTA_PAGE - ? scrollNode.clientHeight - : 1); - const shouldCollapse = recordComposerScrollGestureEvent(composerScrollGestureRef.current, { - now: window.performance.now(), - deltaPx, - collapseThresholdPx: COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX, - collapseEligible: targetsTimeline && composerScrollCollapseEligibleRef.current, - canScrollInGestureDirection, - scrollsTowardLogicalEnd: event.deltaY > 0 && isTimelineAtLogicalEnd(), - }); - if (!shouldCollapse) { - return; - } - - setIsComposerScrollCollapsed(true); - }; - - document.addEventListener("wheel", handleTimelineWheel, { capture: true, passive: true }); - return () => { - document.removeEventListener("wheel", handleTimelineWheel, true); - finishScrollGesture(); - }; - }, [ - canTrackComposerScrollGesture, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, - ]); - - const restingHiddenBlockCount = composerControlsCollapsed + const restingHiddenBlockCount = showInlineRestingControls ? restingControlsHiddenBlockCount - : expandedControlsHiddenBlockCount; - const iconOnlyBlockCount = composerControlsCollapsed + : composerControlsCollapsed + ? 0 + : expandedControlsHiddenBlockCount; + const iconOnlyBlockCount = showInlineRestingControls ? restingControlsIconOnlyBlockCount - : expandedControlsIconOnlyBlockCount; + : composerControlsCollapsed + ? 0 + : expandedControlsIconOnlyBlockCount; const composerControls = ( { composerEditorRef.current?.focusAt(cursor); }, - restoreAfterTimelineReachedEnd, - collapseForTimelineScrollKey: (key) => { - const scrollNode = getTimelineScrollableNode(); - if ( - composerScrollCollapseEligibleRef.current && - scrollNode && - shouldCollapseComposerForScrollKey({ - key, - scrollTop: scrollNode.scrollTop, - scrollHeight: scrollNode.scrollHeight, - clientHeight: scrollNode.clientHeight, - isAtLogicalEnd: isTimelineAtLogicalEnd(), - }) - ) { - setIsComposerScrollCollapsed(true); - } - }, addDroppedFiles: (files: File[]) => { void addComposerAttachments(files).then((inserted) => { if (!inserted) focusComposer(); @@ -3112,10 +2857,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; } - flushSync(() => { - setIsComposerScrollCollapsed(false); - setIsComposerFocused(true); - }); + flushSync(() => setIsComposerFocused(true)); const shell = composerFormRef.current?.closest('[data-slot="composer-shell"]'); const trigger = Array.from( shell?.querySelectorAll( @@ -3127,6 +2869,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); if (!trigger) return; trigger.focus({ preventScroll: true }); + if (command === "composer.mode") markOpenToPermissions(trigger); trigger.click(); }, compactContext: compactThreadContextStable, @@ -3290,10 +3033,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) interactionMode, planModeUiEnabled, compactThreadContextStable, - restoreAfterTimelineReachedEnd, - getTimelineScrollableNode, - isTimelineAtLogicalEnd, - setIsComposerScrollCollapsed, ], ); @@ -3302,11 +3041,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) !isComposerCollapsedMobile && (uncommittedSnapShotIds.length > 0 || composerVideos.length > 0 || - expandedComposerImages.length > 0 || + standaloneComposerImages.length > 0 || preparingComposerImages.length > 0) ? ( { - const target = event.target; - if (isInsideRestingComposerControlScope(target)) return; - if (isInsideCollapsedComposerControls(target)) return; - if (!(target instanceof Element)) return; - const isInteractive = Boolean( - target.closest('button, a, input, select, [role="button"], [role="menuitem"]'), - ); - if (isInteractive) return; - - setIsComposerScrollCollapsed(false); - if (isComposerResting && !target.closest('[data-testid="composer-editor"]')) { - event.preventDefault(); - setIsComposerFocused(true); - scheduleComposerFocus(); - } - }} onFocusCapture={(event) => { const activeElement = event.target; if ( @@ -3381,9 +3103,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) if (isInsideCollapsedComposerControls(activeElement)) { return; } - if (!windowRefocusInFlightRef.current) { - setIsComposerScrollCollapsed(false); - } if (composerBlurFrameRef.current !== null) { window.cancelAnimationFrame(composerBlurFrameRef.current); composerBlurFrameRef.current = null; @@ -3419,23 +3138,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) data-chat-composer-form="true" {...threadContextDropTargetProps()} > - {composerControlsCollapsed && restingControlsHost - ? createPortal( -
    - {composerControls} -
    , - restingControlsHost, - ) - : null} @@ -3479,10 +3181,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
    {isStashMenuOpen && !composerMenuOpen && ( @@ -3621,14 +3319,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) {composerAttachmentFileList} -
    +
    {previewFile ? ( - {isComposerResting ? collapsedComposerImagePreviews : null} {showMobilePendingAnswerActions ? (
    0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", showMobilePendingAnswerActions && "hidden sm:flex", - isComposerResting && - "absolute right-px z-10 h-12 w-auto gap-0 py-0 sm:gap-0 sm:py-0", - isComposerResting && - (showInlineRestingControls ? "bottom-[calc(2rem+1px)]" : "bottom-px"), )} > + {composerAttachButton ?
    {composerAttachButton}
    : null}
    - {composerControlsCollapsed ? null : composerControls} -
    - -
    - {composerAttachButton} +
    + {composerControls} +
    ; + runtimeModeOptions: ReadonlyArray; showInteractionModeToggle: boolean; traitsMenuContent?: ReactNode; + showPermissions?: boolean; size?: "sm" | "xs"; /** Close the portaled popup when its trigger hides. */ hidden?: boolean; @@ -31,17 +30,28 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls const composerFloatingLayerProps = useComposerMenuProps(); const size = props.size ?? "sm"; const [open, setOpen] = useComposerMenuState(props.hidden); + const triggerRef = useRef(null); return ( - + focusRequestedPermission(isOpen, triggerRef.current)} + > } @@ -52,7 +62,9 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls {props.traitsMenuContent ? ( <> {props.traitsMenuContent} - + {props.showInteractionModeToggle || props.showPermissions !== false ? ( + + ) : null} ) : null} {props.showInteractionModeToggle ? ( @@ -68,23 +80,16 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls Chat Plan - + {props.showPermissions !== false ? : null} ) : null} -
    Access
    - { - if (!value || value === props.runtimeMode) return; - props.onRuntimeModeChange(value as RuntimeMode); - }} - > - {props.runtimeModeOptions.map((option) => ( - - {option.label} - - ))} - + {props.showPermissions === false ? null : ( + + )}
    ); diff --git a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx index 2a0c0f7b1a1ad..e3560312925e5 100644 --- a/apps/web/src/components/chat/ComposerAttachmentStrip.tsx +++ b/apps/web/src/components/chat/ComposerAttachmentStrip.tsx @@ -707,7 +707,8 @@ export function ComposerAttachButton({ /> } > - + {/* The clip's weight sits in its upper loop; 1px down reads as centered on the row. */} + Attach files diff --git a/apps/web/src/components/chat/ComposerControl.tsx b/apps/web/src/components/chat/ComposerControl.tsx index 6f1824fd72642..9ec5d4e1144d4 100644 --- a/apps/web/src/components/chat/ComposerControl.tsx +++ b/apps/web/src/components/chat/ComposerControl.tsx @@ -1,11 +1,9 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useRender } from "@base-ui/react/use-render"; -import type { ComponentProps, ComponentType, SVGProps } from "react"; -import { ChevronDownIcon } from "lucide-react"; +import type { ComponentType, SVGProps } from "react"; import { cn } from "~/lib/utils"; -import { Separator } from "../ui/separator"; export type ComposerControlSize = "sm" | "xs"; @@ -66,43 +64,6 @@ export function ComposerControlIcon({ ); } -export function ComposerControlChevron({ - className, - size = "sm", -}: { - className?: string; - size?: ComposerControlSize; -} = {}) { - return ( -